feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
@@ -0,0 +1,197 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
/// <summary>
/// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende.
/// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen —
/// alle Sendefehler werden geloggt und verschluckt.
///
/// <para><b>Sauberes Beenden.</b> Beim Herunterfahren geht ein Heartbeat mit
/// <c>status: "stopped"</c> raus. Der Evaluator lässt einen so gemeldeten Monitor in
/// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen
/// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den
/// Monitor-Zustand.</para>
/// </summary>
public sealed class WatchdogHeartbeatService : IAsyncDisposable
{
private readonly IWatchdogClient _client;
private readonly bool _ownsClient;
private readonly IInstanceHealthProvider _health;
private readonly int _intervalSeconds;
private readonly ILogger _logger;
private CancellationTokenSource? _cts;
private Task? _loop;
private string _lastState = "unknown";
public WatchdogHeartbeatService(
IWatchdogClient client,
IInstanceHealthProvider health,
int intervalSeconds,
ILogger logger,
bool ownsClient = false)
{
_client = client;
_health = health;
_intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400);
_logger = logger;
_ownsClient = ownsClient;
}
/// <summary>
/// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration
/// (fehlende oder nicht-HTTPS-URL).
/// </summary>
public static WatchdogHeartbeatService Create(
string baseUrl, string token, string source, string instance, string group, string os,
string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger)
{
var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version);
return new WatchdogHeartbeatService(
client, health, intervalSeconds, logger, ownsClient: true);
}
public bool IsRunning => _loop is { IsCompleted: false };
/// <summary>Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.</summary>
public string LastState => _lastState;
public void Start()
{
if (IsRunning)
return;
_cts = new CancellationTokenSource();
_loop = RunAsync(_cts.Token);
_logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds);
}
private async Task RunAsync(CancellationToken ct)
{
await TrySendAsync(
() => _client.SendEventAsync(
WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct),
"Start-Ereignis").ConfigureAwait(false);
// Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen
// Intervall im Dashboard auftaucht.
await BeatAsync(ct).ConfigureAwait(false);
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds));
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
await BeatAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Regulärer Stopp.
}
}
private async Task BeatAsync(CancellationToken ct)
{
InstanceHealth health;
try
{
health = await _health.GetAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen
// rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall.
_logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen melde warning.");
health = new InstanceHealth(
WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.",
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>());
}
await TrySendAsync(async () =>
{
var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct)
.ConfigureAwait(false);
if (result.State != _lastState)
{
_logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}",
_lastState, result.State,
result.FailingChecks.Count > 0
? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})"
: "");
_lastState = result.State;
}
}, "Heartbeat").ConfigureAwait(false);
}
private async Task TrySendAsync(Func<Task> send, string what)
{
try
{
await send().ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem)
{
// Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor
// für immer stumm, und niemand merkt es, weil ja nichts abstürzt.
_logger.LogWarning(
"Watchdog: {What} abgelehnt ({Code}) Token prüfen (Recht watchdog:ping).",
what, ex.Code);
}
catch (Exception ex)
{
// Ausfall des Monitorings darf den Betrieb nie stören.
_logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what);
}
}
public async ValueTask DisposeAsync()
{
if (_cts is null)
return;
await _cts.CancelAsync().ConfigureAwait(false);
if (_loop is not null)
{
try { await _loop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); }
}
// Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein
// Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft.
try
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await _client.SendHeartbeatAsync(
new InstanceHealth(
WatchdogStatus.Stopped, "Instanz planmäßig beendet.",
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>()),
_intervalSeconds, stopCts.Token).ConfigureAwait(false);
await _client.SendEventAsync(
WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, stopCts.Token)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert).");
}
_cts.Dispose();
if (_ownsClient && _client is IDisposable disposable)
disposable.Dispose();
}
}