using System.Text.Json; namespace ClawdDotNet.Core.Deploymentcenter.Watchdog; /// Was der Server zu einem Heartbeat zurückmeldet. /// Der daraus abgeleitete Monitor-Zustand (up, warning, …). /// Welche der mitgeschickten Prüfungen fehlgeschlagen sind. public sealed record WatchdogPingResult(string State, IReadOnlyList FailingChecks); /// /// Sendet Heartbeats und Ereignisse an das Watchdog-Modul des Deploymentcenters. /// public interface IWatchdogClient { Task SendHeartbeatAsync( InstanceHealth health, int intervalSeconds, CancellationToken ct); Task SendEventAsync( string kind, string severity, string? message, object? meta, CancellationToken ct); } /// /// Watchdog-Anbindung: POST /api/watchdog/v1/ping und /// POST /api/watchdog/v1/event. /// /// Ein Monitor je Instanz. Der Schlüssel des Monitors ist das Paar /// source + instance (so das Datenbankschema: /// UNIQUE KEY uq_monitor (source, instance)). Alle ClawdDotNet-Instanzen melden /// unter derselben source und tragen ihre eigene instance — 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. /// /// Der Monitor entsteht beim ersten Heartbeat von selbst (INSERT … ON DUPLICATE /// KEY UPDATE) — eine Registrierung vorab gibt es nicht mehr und ist auch nicht /// nötig. /// 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 SendHeartbeatAsync( InstanceHealth health, int intervalSeconds, CancellationToken ct) { var payload = new Dictionary { ["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(); 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); } /// /// Ein einmaliges Vorkommnis statt einer zyklischen Meldung. Zulässige /// -Werte siehe — der Server /// weist andere ab. /// 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(); } } /// /// Die vom Server akzeptierten Ereignisarten. Die frühere Anbindung schickte /// start und stop — beide stehen nicht auf dieser Liste und wurden /// stillschweigend als started abgelegt. /// 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"; }