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,54 @@
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
/// <summary>
/// Die Statuswerte, die der Watchdog kennt. <c>stopped</c> und <c>maintenance</c> sind
/// angekündigte Zustände — der Evaluator lässt solche Monitore in Ruhe, statt wenige
/// Minuten nach einem geplanten Herunterfahren einen Fehlalarm zu erzeugen.
/// </summary>
public static class WatchdogStatus
{
public const string Ok = "ok";
public const string Warning = "warning";
public const string Error = "error";
public const string Stopped = "stopped";
public const string Maintenance = "maintenance";
}
/// <summary>
/// Eine selbst ermittelte Teilprüfung. Das Deploymentcenter interpretiert den Namen
/// nicht — es liest nur <see cref="Ok"/> und <see cref="Message"/>. Was „gesund"
/// bedeutet, entscheidet damit jede Anwendung selbst.
///
/// <para>Schlägt eine Prüfung fehl, stuft der Server einen als <c>ok</c> gemeldeten
/// Heartbeat auf <c>warning</c> herab. Das ist der Unterschied zwischen „ein Faden
/// läuft" und „die Anwendung tut, was sie soll".</para>
/// </summary>
public sealed record HealthCheck(bool Ok, string? Message = null);
/// <summary>Momentaufnahme des Instanz-Zustands für einen Heartbeat.</summary>
/// <param name="Status">Einer der Werte aus <see cref="WatchdogStatus"/>.</param>
/// <param name="Message">Kurzbegründung, erscheint im Dashboard.</param>
/// <param name="Metrics">
/// Nur Zahlen: Das Deploymentcenter legt sie mit Zeitstempel ab (14 Tage) und vergleicht
/// den aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische
/// Werte würden dabei stillschweigend verworfen — beschreibende Angaben gehören
/// deshalb in <paramref name="Message"/> oder in die Checks.
/// </param>
/// <param name="Checks">Selbst ermittelter Gesundheitszustand je Teilbereich.</param>
public sealed record InstanceHealth(
string Status,
string? Message,
IReadOnlyDictionary<string, double> Metrics,
IReadOnlyDictionary<string, HealthCheck> Checks)
{
public static InstanceHealth Ok(string? message = null) => new(
WatchdogStatus.Ok, message,
new Dictionary<string, double>(),
new Dictionary<string, HealthCheck>());
}
/// <summary>Liefert vor jedem Heartbeat den aktuellen Instanz-Zustand.</summary>
public interface IInstanceHealthProvider
{
Task<InstanceHealth> GetAsync(CancellationToken ct);
}
@@ -0,0 +1,128 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
/// <summary>
/// Leitet den Instanz-Zustand für den Heartbeat ab.
///
/// <para>Ein Heartbeat allein beweist nur, dass ein Faden läuft. Deshalb geht der
/// selbst ermittelte Gesundheitszustand als <c>checks</c> mit — der Server stuft einen
/// als <c>ok</c> gemeldeten Beat herab, sobald eine Prüfung fehlschlägt, und nennt in
/// der Antwort die betroffene. Der klassische Fall, den das abfängt: Der Takt meldet
/// brav <c>ok</c>, während der Aufgaben-Scanner seit einer Stunde tot ist.</para>
///
/// <list type="bullet">
/// <item><c>error</c> — kein OpenRouter-Key konfiguriert (Agenten deaktiviert).</item>
/// <item><c>warning</c> — Tagesbudget der Instanz erschöpft oder Scanner steht.</item>
/// <item><c>ok</c> — sonst.</item>
/// </list>
///
/// <para>Die Metriken sind bewusst schlank und ausschließlich numerisch: keine
/// sensiblen Nutzdaten, und nur Zahlen landen im Verlauf.</para>
/// </summary>
public sealed class InstanceHealthProvider : IInstanceHealthProvider
{
private readonly string _instanceName;
private readonly bool _agentsEnabled;
private readonly InstanceBudget _budget;
private readonly IUsageRepository? _usage;
private readonly Func<int> _agentCount;
private readonly Func<int> _runningChats;
private readonly Func<bool>? _schedulerRunning;
private readonly Func<DateTime> _now;
public InstanceHealthProvider(
string instanceName,
bool agentsEnabled,
InstanceBudget budget,
IUsageRepository? usage,
Func<int> agentCount,
Func<int> runningChats,
Func<bool>? schedulerRunning = null,
Func<DateTime>? now = null)
{
_instanceName = instanceName;
_agentsEnabled = agentsEnabled;
_budget = budget;
_usage = usage;
_agentCount = agentCount;
_runningChats = runningChats;
_schedulerRunning = schedulerRunning;
_now = now ?? (() => DateTime.Now);
}
public async Task<InstanceHealth> GetAsync(CancellationToken ct)
{
var metrics = new Dictionary<string, double>
{
["agentCount"] = _agentCount(),
["runningChats"] = _runningChats()
};
var checks = new Dictionary<string, HealthCheck>
{
["agents"] = new(_agentsEnabled,
_agentsEnabled ? null : "Kein OpenRouter-API-Key konfiguriert.")
};
if (_schedulerRunning is not null)
{
var running = _schedulerRunning();
checks["scheduler"] = new(running,
running ? null : "Aufgaben-Scanner läuft nicht.");
}
string? budgetProblem = null;
if (_usage is not null)
{
var today = DateOnly.FromDateTime(_now());
var used = await _usage.GetDailyAsync(today, agentId: "", ct).ConfigureAwait(false);
metrics["todayCostUsd"] = (double)decimal.Round(used.CostUsd, 4);
metrics["todayTokens"] = used.TotalTokens;
if (Exceeds(_budget.DailyCostUsd, used.CostUsd))
{
budgetProblem =
$"Tagesbudget erschöpft: {used.CostUsd:F2} von {_budget.DailyCostUsd:F2} USD.";
}
else if (Exceeds(_budget.DailyTokens, used.TotalTokens))
{
budgetProblem =
$"Token-Tageslimit erschöpft: {used.TotalTokens:N0} von {_budget.DailyTokens:N0}.";
}
checks["budget"] = new(budgetProblem is null, budgetProblem);
}
// Der Instanzname steht in der Meldung, nicht in den Metriken: Metriken sind
// Zahlen, alles andere würde der Server beim Verdichten ohnehin verwerfen.
if (!_agentsEnabled)
{
return new InstanceHealth(
WatchdogStatus.Error,
$"{_instanceName}: Kein OpenRouter-API-Key konfiguriert Agenten deaktiviert.",
metrics, checks);
}
if (budgetProblem is not null)
return new InstanceHealth(WatchdogStatus.Warning, $"{_instanceName}: {budgetProblem}", metrics, checks);
if (_schedulerRunning is not null && !_schedulerRunning())
{
return new InstanceHealth(
WatchdogStatus.Warning,
$"{_instanceName}: Aufgaben-Scanner läuft nicht.",
metrics, checks);
}
return new InstanceHealth(WatchdogStatus.Ok, $"{_instanceName}: Betrieb normal.", metrics, checks);
}
/// <summary>0 oder kleiner bedeutet: keine Grenze gesetzt. Deckungsgleich mit BudgetGuard.</summary>
private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit;
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
}
@@ -0,0 +1,174 @@
using System.Text.Json;
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
/// <summary>Was der Server zu einem Heartbeat zurückmeldet.</summary>
/// <param name="State">Der daraus abgeleitete Monitor-Zustand (<c>up</c>, <c>warning</c>, …).</param>
/// <param name="FailingChecks">Welche der mitgeschickten Prüfungen fehlgeschlagen sind.</param>
public sealed record WatchdogPingResult(string State, IReadOnlyList<string> FailingChecks);
/// <summary>
/// Sendet Heartbeats und Ereignisse an das Watchdog-Modul des Deploymentcenters.
/// </summary>
public interface IWatchdogClient
{
Task<WatchdogPingResult> SendHeartbeatAsync(
InstanceHealth health, int intervalSeconds, CancellationToken ct);
Task SendEventAsync(
string kind, string severity, string? message, object? meta, CancellationToken ct);
}
/// <summary>
/// Watchdog-Anbindung: <c>POST /api/watchdog/v1/ping</c> und
/// <c>POST /api/watchdog/v1/event</c>.
///
/// <para><b>Ein Monitor je Instanz.</b> Der Schlüssel des Monitors ist das Paar
/// <c>source</c> + <c>instance</c> (so das Datenbankschema:
/// <c>UNIQUE KEY uq_monitor (source, instance)</c>). Alle ClawdDotNet-Instanzen melden
/// unter derselben <c>source</c> und tragen ihre eigene <c>instance</c> — damit ist jede
/// laufende Instanz ein eigener Monitor mit eigenem Zustand, eigenem Intervall und
/// eigenem Metrik-Verlauf. Stürzt eine von dreien ab, fällt genau deren Monitor.</para>
///
/// <para>Der Monitor entsteht beim ersten Heartbeat von selbst (<c>INSERT … ON DUPLICATE
/// KEY UPDATE</c>) — eine Registrierung vorab gibt es nicht mehr und ist auch nicht
/// nötig.</para>
/// </summary>
public sealed class WatchdogClient : IWatchdogClient, IDisposable
{
private readonly DeploymentcenterApi _api;
private readonly bool _ownsApi;
private readonly string _source;
private readonly string _instance;
private readonly string _group;
private readonly string _os;
private readonly string _version;
public WatchdogClient(
DeploymentcenterApi api, string source, string instance, string group, string os,
string version, bool ownsApi = false)
{
_api = api;
_ownsApi = ownsApi;
_source = source;
_instance = instance;
_group = group;
_os = os;
_version = version;
}
public static WatchdogClient Create(
string baseUrl, string token, string source, string instance, string group, string os,
string version, HttpClient? httpClient = null)
=> new(new DeploymentcenterApi(baseUrl, token, httpClient),
source, instance, group, os, version, ownsApi: true);
public async Task<WatchdogPingResult> SendHeartbeatAsync(
InstanceHealth health, int intervalSeconds, CancellationToken ct)
{
var payload = new Dictionary<string, object?>
{
["source"] = _source,
["instance"] = _instance,
["type"] = "heartbeat",
["status"] = health.Status,
["interval"] = intervalSeconds,
["message"] = health.Message,
["group"] = _group,
["os"] = _os,
// Landet in watchdog_monitors.app_version. Damit steht im Dashboard, welche
// Fassung eine Instanz gerade fährt — bei mehreren Instanzen der
// Unterschied zwischen „läuft" und „läuft noch auf der alten Version".
["version"] = _version
};
// Leere Objekte weglassen: Der Server übernimmt health_json nur, wenn etwas
// mitkommt — ein leeres würde den letzten bekannten Zustand nicht ersetzen,
// aber unnötig Platz im Protokoll kosten.
if (health.Checks.Count > 0)
{
payload["checks"] = health.Checks.ToDictionary(
c => c.Key,
c => (object)new { ok = c.Value.Ok, message = c.Value.Message });
}
if (health.Metrics.Count > 0)
payload["metrics"] = health.Metrics;
var response = await _api.PostAsync("/api/watchdog/v1/ping", payload, ct)
.ConfigureAwait(false);
return ReadPingResult(response);
}
private static WatchdogPingResult ReadPingResult(JsonElement response)
{
if (!response.TryGetProperty("monitor", out var monitor)
|| monitor.ValueKind != JsonValueKind.Object)
{
return new WatchdogPingResult("unknown", []);
}
var state = monitor.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String
? s.GetString() ?? "unknown"
: "unknown";
var failing = new List<string>();
if (monitor.TryGetProperty("failing_checks", out var checks)
&& checks.ValueKind == JsonValueKind.Array)
{
failing.AddRange(checks.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString()!));
}
return new WatchdogPingResult(state, failing);
}
/// <summary>
/// Ein einmaliges Vorkommnis statt einer zyklischen Meldung. Zulässige
/// <paramref name="kind"/>-Werte siehe <see cref="WatchdogEventKind"/> — der Server
/// weist andere ab.
/// </summary>
public async Task SendEventAsync(
string kind, string severity, string? message, object? meta, CancellationToken ct)
{
var payload = new
{
source = _source,
instance = _instance,
kind,
severity,
message,
meta
};
await _api.PostAsync("/api/watchdog/v1/event", payload, ct).ConfigureAwait(false);
}
public void Dispose()
{
if (_ownsApi)
_api.Dispose();
}
}
/// <summary>
/// Die vom Server akzeptierten Ereignisarten. Die frühere Anbindung schickte
/// <c>start</c> und <c>stop</c> — beide stehen nicht auf dieser Liste und wurden
/// stillschweigend als <c>started</c> abgelegt.
/// </summary>
public static class WatchdogEventKind
{
public const string Started = "started";
public const string StoppedGraceful = "stopped_graceful";
public const string CrashSuspected = "crash_suspected";
public const string HardError = "hard_error";
public const string Recovered = "recovered";
public const string WarningRaised = "warning_raised";
public const string WarningCleared = "warning_cleared";
public const string MaintenanceStart = "maintenance_start";
public const string MaintenanceEnd = "maintenance_end";
public const string WatchdogStarted = "watchdog_started";
}
@@ -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();
}
}