feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Die Anbindung ans Deploymentcenter, an einer Stelle gebündelt: Watchdog-Heartbeat,
|
||||
/// Fehler-Stream, Bugtracker und Update-Prüfung.
|
||||
///
|
||||
/// <para>Zuvor lagen Watchdog (eigener Server, eigener Schlüssel) und Lizenz
|
||||
/// (LicenseLabrador, eigener Server, eigener Public-Key) getrennt nebeneinander. Beides
|
||||
/// sind jetzt Module derselben Anwendung mit einer Adresse und einem Token — und dazu
|
||||
/// kommen Updates, Fehler-Stream und Bugtracker, die es vorher gar nicht gab.</para>
|
||||
///
|
||||
/// <para>Die Lizenz bleibt bewusst außen vor: Sie hat ein eigenes Antwortformat (kein
|
||||
/// <c>status</c>/<c>error</c>-Umschlag), einen eigenen Zwischenspeicher und muss vor
|
||||
/// allem anderen laufen. Dafür ist <see cref="LicenseGate"/> zuständig.</para>
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterService : IAsyncDisposable
|
||||
{
|
||||
private readonly DeploymentcenterApi _api;
|
||||
private readonly string _appToken;
|
||||
private readonly string _build;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private WatchdogHeartbeatService? _heartbeat;
|
||||
|
||||
public IErrorReporter Errors { get; private set; } = NullErrorReporter.Instance;
|
||||
|
||||
/// <summary>Null, wenn kein Token hinterlegt ist — dann lässt sich nichts melden.</summary>
|
||||
public BugtrackerClient? Bugtracker { get; private set; }
|
||||
|
||||
/// <summary>Ergebnis der Update-Prüfung beim Start; null, solange sie nicht durch ist.</summary>
|
||||
public UpdateAvailability? Update { get; private set; }
|
||||
|
||||
private DeploymentcenterService(
|
||||
DeploymentcenterApi api, string appToken, string build, ILogger logger)
|
||||
{
|
||||
_api = api;
|
||||
_appToken = appToken;
|
||||
_build = build;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Anbindung auf, soweit sie konfiguriert ist. Gibt <c>null</c> zurück,
|
||||
/// wenn Adresse oder Token fehlen — der Aufrufer läuft dann ohne weiter, denn keines
|
||||
/// dieser Module darf ein Startgrund oder ein Hindernis sein.
|
||||
/// </summary>
|
||||
public static DeploymentcenterService? TryCreate(
|
||||
AppSettings settings, string build, ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Deploymentcenter");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.DeploymentcenterUrl))
|
||||
{
|
||||
logger.LogInformation("Deploymentcenter: keine Server-URL hinterlegt – Anbindung aus.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.DeploymentcenterToken))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Deploymentcenter: kein Token hinterlegt – Heartbeat, Fehler-Stream und "
|
||||
+ "Bugtracker bleiben aus.");
|
||||
return null;
|
||||
}
|
||||
|
||||
DeploymentcenterApi api;
|
||||
try
|
||||
{
|
||||
api = new DeploymentcenterApi(settings.DeploymentcenterUrl, settings.DeploymentcenterToken);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Deploymentcenter: Konfiguration unbrauchbar – Anbindung aus.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var service = new DeploymentcenterService(
|
||||
api, settings.DeploymentcenterToken, build, logger);
|
||||
|
||||
if (settings.ErrorReportingEnabled)
|
||||
{
|
||||
service.Errors = new ErrorReporter(
|
||||
api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build, logger);
|
||||
}
|
||||
|
||||
service.Bugtracker = new BugtrackerClient(
|
||||
api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build);
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
// ─── Watchdog ───
|
||||
|
||||
/// <summary>
|
||||
/// Startet den Instanz-Heartbeat, wenn der eingebaute Dienst eingeschaltet ist.
|
||||
///
|
||||
/// <para>Jede laufende Instanz ist ein eigener Monitor: Der Server führt sie über
|
||||
/// das Paar <c>source</c> + <c>instance</c>. Fällt eine von mehreren aus, fällt
|
||||
/// genau deren Monitor — und nur der schlägt Alarm.</para>
|
||||
/// </summary>
|
||||
/// <param name="saveInstanceConfig">
|
||||
/// Wird aufgerufen, wenn ein neu bezogenes Sub-Token in die Instanzkonfiguration
|
||||
/// geschrieben werden soll (dort verschlüsselt).
|
||||
/// </param>
|
||||
public async Task StartWatchdogAsync(
|
||||
InstanceConfig instance,
|
||||
IInstanceHealthProvider health,
|
||||
Action saveInstanceConfig,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var service = instance.Services.FirstOrDefault(s => s.Type == BuiltInServices.InstanceWatchdog);
|
||||
if (service is not { Enabled: true, AutoStart: true })
|
||||
return;
|
||||
|
||||
var watchdog = instance.Watchdog;
|
||||
var token = await ResolveInstanceTokenAsync(instance, saveInstanceConfig, ct);
|
||||
|
||||
try
|
||||
{
|
||||
_heartbeat = WatchdogHeartbeatService.Create(
|
||||
_api.BaseUrl,
|
||||
token,
|
||||
watchdog.Source,
|
||||
watchdog.ResolveInstance(instance.InstanceId),
|
||||
watchdog.Group,
|
||||
DescribePlatform(),
|
||||
_build,
|
||||
watchdog.IntervalSeconds,
|
||||
health,
|
||||
_logger);
|
||||
|
||||
_heartbeat.Start();
|
||||
|
||||
_logger.LogInformation("Instanz-Watchdog aktiv: {Source}/{Instance}, alle {Interval}s",
|
||||
watchdog.Source, watchdog.ResolveInstance(instance.InstanceId), watchdog.IntervalSeconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Instanz-Watchdog konnte nicht gestartet werden");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liefert das Token, mit dem diese Instanz meldet: das zwischengespeicherte
|
||||
/// Sub-Token, sonst ein frisch gezogenes, sonst das anwendungsweite.
|
||||
///
|
||||
/// <para>Der Umweg lohnt sich, weil danach auf der Instanz nicht mehr das
|
||||
/// Master-Token liegt, sondern ein auf <c>watchdog:ping</c> und
|
||||
/// <c>bugtracker:report</c> beschränktes, das sich einzeln widerrufen lässt.
|
||||
/// Scheitert das, wird trotzdem gemeldet — Monitoring, das nur bei perfekter
|
||||
/// Rechtelage läuft, ist genau dann still, wenn man es braucht.</para>
|
||||
/// </summary>
|
||||
private async Task<string> ResolveInstanceTokenAsync(
|
||||
InstanceConfig instance, Action saveInstanceConfig, CancellationToken ct)
|
||||
{
|
||||
if (instance.Watchdog.HasToken)
|
||||
return instance.Watchdog.AgentToken;
|
||||
|
||||
try
|
||||
{
|
||||
var provisioned = await new TokenProvisioner(_api).ProvisionAsync(
|
||||
clientName: $"ClawdDotNet {instance.InstanceName}",
|
||||
instanceId: instance.InstanceId,
|
||||
scopes: TokenProvisioner.InstanceScopes,
|
||||
ct: ct);
|
||||
|
||||
instance.Watchdog.AgentToken = provisioned.Token;
|
||||
saveInstanceConfig();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deploymentcenter: eigenes Token für diese Instanz bezogen ({TokenId}, Rechte: {Scopes})",
|
||||
provisioned.TokenId, string.Join(", ", provisioned.Scopes));
|
||||
|
||||
return provisioned.Token;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Häufigster Fall: Das hinterlegte Token ist selbst ein Sub-Token und darf
|
||||
// keine weiteren ausstellen. Kein Grund, das Monitoring aufzugeben.
|
||||
_logger.LogInformation(
|
||||
"Deploymentcenter: kein eigenes Instanz-Token beziehbar ({Reason}) – "
|
||||
+ "es wird mit dem hinterlegten Token gemeldet.", ex.Message);
|
||||
|
||||
return _appToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribePlatform() =>
|
||||
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} / "
|
||||
+ $".NET {System.Environment.Version}";
|
||||
|
||||
// ─── Updates ───
|
||||
|
||||
/// <summary>
|
||||
/// Fragt einmalig, ob ein neueres Release vorliegt. Bewusst ohne Folgen: Das
|
||||
/// Ergebnis wird protokolliert und über <see cref="Update"/> bereitgestellt; ob und
|
||||
/// wann aktualisiert wird, entscheidet der Benutzer.
|
||||
/// </summary>
|
||||
public async Task CheckForUpdateAsync(AppSettings settings, string currentVersion, CancellationToken ct = default)
|
||||
{
|
||||
if (!settings.UpdateCheckEnabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var client = new global::Deploymentcenter.Client.UpdateClient();
|
||||
var result = await client.CheckForUpdateAsync(
|
||||
settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion,
|
||||
settings.UpdateChannel, cancellationToken: ct);
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
_logger.LogDebug(result.Error, "Update-Prüfung fehlgeschlagen (ignoriert).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Seit SDK 2.1 liefern beide Wege vollständige Daten: die statische
|
||||
// latest.json in camelCase, die API in snake_case, jeweils über ein eigenes
|
||||
// Modell. Vorher kam über den API-Zweig außer der Versionsnummer nichts an —
|
||||
// und der ist genau der Rückfall, wenn die latest.json fehlt.
|
||||
Update = new UpdateAvailability(
|
||||
result.UpdateAvailable,
|
||||
result.LatestRelease?.Version ?? currentVersion,
|
||||
result.IsCritical,
|
||||
result.LatestRelease?.Changelog,
|
||||
result.LatestRelease?.PackageUrl);
|
||||
|
||||
if (result.UpdateAvailable)
|
||||
{
|
||||
_logger.LogInformation("Update verfügbar: {Version}{Critical}",
|
||||
Update.LatestVersion, Update.IsCritical ? " (kritisch)" : "");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Kein Update verfügbar (installiert: {Version}).", currentVersion);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Update-Prüfung fehlgeschlagen (ignoriert).");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_heartbeat is not null)
|
||||
await _heartbeat.DisposeAsync();
|
||||
|
||||
if (Errors is IDisposable disposableReporter)
|
||||
disposableReporter.Dispose();
|
||||
|
||||
_api.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis der Update-Prüfung.</summary>
|
||||
/// <param name="DownloadUrl">
|
||||
/// Adresse des Pakets — für einen späteren Anschluss des <c>update-agent</c>. Solange
|
||||
/// der nicht eingebunden ist, dient sie nur der Anzeige.
|
||||
/// </param>
|
||||
public sealed record UpdateAvailability(
|
||||
bool IsAvailable, string LatestVersion, bool IsCritical, string? ReleaseNotes,
|
||||
string? DownloadUrl = null);
|
||||
Reference in New Issue
Block a user