feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Was aus einem Bugtracker-Eintrag geworden ist.</summary>
|
||||
/// <param name="ItemId">Nummer des Eintrags im Deploymentcenter.</param>
|
||||
/// <param name="IsNew">False, wenn ein bestehender Eintrag hochgezählt wurde.</param>
|
||||
/// <param name="OccurrenceCount">Wie oft dieses Vorkommnis bisher gezählt wurde.</param>
|
||||
/// <param name="Url">Adresse der Übersicht, für einen Hinweis an den Benutzer.</param>
|
||||
public sealed record BugtrackerReport(long ItemId, bool IsNew, int OccurrenceCount, string? Url);
|
||||
|
||||
/// <summary>Art eines Eintrags. Der Server kennt darüber hinaus noch <c>idea</c>.</summary>
|
||||
public static class BugtrackerItemType
|
||||
{
|
||||
public const string Bug = "bug";
|
||||
public const string Feature = "feature";
|
||||
public const string Idea = "idea";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung an <c>POST /api/bugtracker/v1/report</c> — der Weg, auf dem ClawdDotNet
|
||||
/// selbst (oder ein Benutzer über die Oberfläche) einen Fehler oder Wunsch einträgt.
|
||||
///
|
||||
/// <para>Abgegrenzt vom <see cref="ErrorReporter"/>: Der meldet <em>ungefangene</em>
|
||||
/// Ausnahmen automatisch; hier geht es um bewusst formulierte Einträge mit Titel und
|
||||
/// Beschreibung. Serverseitig landen beide in derselben Tabelle — was richtig ist,
|
||||
/// denn ein zweiter Speicher wäre nur ein zweiter Ort, an dem man suchen müsste.</para>
|
||||
///
|
||||
/// <para><b>Der Absender kommt aus dem Token</b> und lässt sich nicht frei wählen —
|
||||
/// sonst könnte sich ein Agent als ein anderer ausgeben.</para>
|
||||
/// </summary>
|
||||
public sealed class BugtrackerClient(
|
||||
DeploymentcenterApi api, string projectSlug, string environment, string build)
|
||||
{
|
||||
/// <param name="type">Siehe <see cref="BugtrackerItemType"/>.</param>
|
||||
/// <param name="clientRef">
|
||||
/// Freier Idempotenz-Schlüssel. Zweimal derselbe Wert erzeugt keinen zweiten
|
||||
/// Eintrag — nützlich, wenn eine Meldung nach einem Verbindungsabbruch wiederholt
|
||||
/// wird.
|
||||
/// </param>
|
||||
public async Task<BugtrackerReport> ReportAsync(
|
||||
string type,
|
||||
string title,
|
||||
string? description = null,
|
||||
string severity = "medium",
|
||||
string? clientRef = null,
|
||||
IReadOnlyDictionary<string, object?>? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["project_slug"] = projectSlug,
|
||||
["type"] = type,
|
||||
["title"] = title,
|
||||
["description"] = description,
|
||||
["severity"] = severity,
|
||||
["environment"] = environment,
|
||||
["build_version"] = build
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(clientRef))
|
||||
payload["client_ref"] = clientRef;
|
||||
|
||||
if (context is { Count: > 0 })
|
||||
payload["context"] = context;
|
||||
|
||||
var response = await api.PostAsync("/api/bugtracker/v1/report", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new BugtrackerReport(
|
||||
ReadLong(response, "item_id"),
|
||||
ReadBool(response, "is_new"),
|
||||
(int)ReadLong(response, "occurrence_count"),
|
||||
ReadString(response, "url"));
|
||||
}
|
||||
|
||||
private static long ReadLong(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt64()
|
||||
: 0;
|
||||
|
||||
private static bool ReadBool(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Der gemeinsame Unterbau für alle Deploymentcenter-Module (Watchdog, Fehler-Stream,
|
||||
/// Bugtracker, Token-Provisionierung).
|
||||
///
|
||||
/// <para>Alle JSON-Endpunkte antworten einheitlich mit einem Umschlag —
|
||||
/// <c>{"status":"success",…}</c> bzw. <c>{"status":"error","error":{"code":…}}</c>. Der
|
||||
/// <c>code</c> ist stabil und für Programme gedacht, die <c>message</c> für Menschen.
|
||||
/// Diese Klasse packt den Umschlag aus und macht aus einem Fehler eine
|
||||
/// <see cref="DeploymentcenterException"/> mit dem Code daran.</para>
|
||||
///
|
||||
/// <para><b>Ausnahme:</b> Die Lizenz-Endpunkte tragen diesen Umschlag bewusst
|
||||
/// <em>nicht</em> — dort steht im Feld <c>status</c> der Lizenzzustand. Sie werden
|
||||
/// deshalb nicht hierüber, sondern über <c>Deploymentcenter.Client</c> angesprochen.</para>
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterApi : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsHttp;
|
||||
private readonly string _token;
|
||||
|
||||
/// <summary>Die Basis-URL ohne abschließenden Schrägstrich.</summary>
|
||||
public string BaseUrl { get; }
|
||||
|
||||
/// <param name="baseUrl">Basis-URL des Deploymentcenters, etwa <c>https://dc.mhdf.de</c>.</param>
|
||||
/// <param name="token">Token mit den nötigen Rechten. Geht als <c>Authorization: Bearer</c> mit.</param>
|
||||
/// <param name="httpClient">Nur für Tests — sonst wird ein eigener mit Zeitgrenze erstellt.</param>
|
||||
public DeploymentcenterApi(string baseUrl, string token, HttpClient? httpClient = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
throw new ArgumentException("Deploymentcenter-URL fehlt.", nameof(baseUrl));
|
||||
|
||||
// Über eine ungesicherte Verbindung ginge das Token im Klartext. Ausnahme ist
|
||||
// nur der eigene Rechner — dort gibt es keine Strecke, auf der jemand mithören
|
||||
// könnte, und eine lokale Testinstallation hat selten ein Zertifikat.
|
||||
if (!IsAcceptableUrl(baseUrl))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Deploymentcenter-URL muss mit https:// beginnen (Ausnahme: localhost).",
|
||||
nameof(baseUrl));
|
||||
}
|
||||
|
||||
BaseUrl = baseUrl.TrimEnd('/');
|
||||
_token = token ?? throw new ArgumentNullException(nameof(token));
|
||||
|
||||
_ownsHttp = httpClient is null;
|
||||
_http = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
|
||||
private static bool IsAcceptableUrl(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
return true;
|
||||
|
||||
return uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback;
|
||||
}
|
||||
|
||||
public async Task<JsonElement> PostAsync(string path, object payload, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl + path)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return await SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<JsonElement> GetAsync(string path, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path);
|
||||
return await SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<JsonElement> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
if (_token.Length > 0)
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _token);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
|
||||
JsonElement root;
|
||||
try
|
||||
{
|
||||
// Geklont, weil das JsonDocument am Ende dieses Blocks freigegeben wird —
|
||||
// ein JsonElement daraus wäre danach nicht mehr lesbar.
|
||||
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
||||
root = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"invalid_response",
|
||||
$"Antwort war kein JSON (HTTP {(int)response.StatusCode}).",
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
if (IsErrorEnvelope(root, out var code, out var message))
|
||||
throw new DeploymentcenterException(code, message, response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"http_error",
|
||||
$"Deploymentcenter antwortete HTTP {(int)response.StatusCode}.",
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static bool IsErrorEnvelope(JsonElement root, out string code, out string message)
|
||||
{
|
||||
code = "error";
|
||||
message = "Unbekannter Fehler.";
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
|
||||
if (!root.TryGetProperty("status", out var status)
|
||||
|| status.ValueKind != JsonValueKind.String
|
||||
|| !string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String)
|
||||
code = c.GetString() ?? code;
|
||||
|
||||
if (error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
|
||||
message = m.GetString() ?? message;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp)
|
||||
_http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein vom Deploymentcenter abgelehnter Aufruf. <see cref="Code"/> ist der stabile
|
||||
/// Fehlercode aus dem Umschlag (<c>unauthorized</c>, <c>rate_limited</c>, …) — er ist
|
||||
/// zum Auswerten gedacht, der Text nicht.
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode)
|
||||
: Exception($"{message} [{code}]")
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
|
||||
public HttpStatusCode StatusCode { get; } = statusCode;
|
||||
|
||||
/// <summary>Token fehlt, ist abgelaufen oder deckt das nötige Recht nicht ab.</summary>
|
||||
public bool IsAuthorizationProblem =>
|
||||
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Meldet Laufzeitfehler an das Deploymentcenter.</summary>
|
||||
public interface IErrorReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Meldet eine Ausnahme. <paramref name="fatal"/> heißt: Der Prozess endet daran.
|
||||
/// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen.
|
||||
/// </summary>
|
||||
Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Tut nichts. Für abgeschaltete Meldung und für Tests.</summary>
|
||||
public sealed class NullErrorReporter : IErrorReporter
|
||||
{
|
||||
public static readonly NullErrorReporter Instance = new();
|
||||
|
||||
public Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung an <c>POST /api/errors/v1/report</c> — den Eingang für den globalen
|
||||
/// Ausnahmebehandler.
|
||||
///
|
||||
/// <para>Gespeichert wird serverseitig in derselben Tabelle wie der Bugtracker. Die
|
||||
/// Trennung von Rauschen und Signal leisten dort die Ignore-Regeln: Ein bekannter,
|
||||
/// harmloser Fehler wird weiterhin gezählt, bleibt aber aus der Übersicht — und schlägt
|
||||
/// Alarm, wenn er plötzlich hundertmal so oft auftritt.</para>
|
||||
///
|
||||
/// <para><b>Eigener Schutz gegen Fehlerschleifen.</b> Der Server drosselt auf 300
|
||||
/// Meldungen pro Minute und IP. Diese Klasse drosselt schon vorher: Derselbe Fehler
|
||||
/// (gleicher Typ, gleiche Stelle) geht höchstens einmal je Zeitfenster raus. Ohne das
|
||||
/// erzeugt eine Schleife in einem Timer tausende identische Anfragen, die der Server
|
||||
/// dann verwerfen muss — und der einzige, der davon etwas hat, ist die Leitung.</para>
|
||||
/// </summary>
|
||||
public sealed class ErrorReporter : IErrorReporter, IDisposable
|
||||
{
|
||||
/// <summary>Wie lange derselbe Fehler nach einer Meldung stumm bleibt.</summary>
|
||||
private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab.</summary>
|
||||
private const int MaxStackTraceLength = 8000;
|
||||
|
||||
private readonly DeploymentcenterApi _api;
|
||||
private readonly bool _ownsApi;
|
||||
private readonly string _projectSlug;
|
||||
private readonly string _environment;
|
||||
private readonly string _build;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<DateTimeOffset> _now;
|
||||
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastSent = [];
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public ErrorReporter(
|
||||
DeploymentcenterApi api,
|
||||
string projectSlug,
|
||||
string environment,
|
||||
string build,
|
||||
ILogger logger,
|
||||
bool ownsApi = false,
|
||||
Func<DateTimeOffset>? now = null)
|
||||
{
|
||||
_api = api;
|
||||
_projectSlug = projectSlug;
|
||||
_environment = environment;
|
||||
_build = build;
|
||||
_logger = logger;
|
||||
_ownsApi = ownsApi;
|
||||
_now = now ?? (() => DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!ShouldSend(exception))
|
||||
return false;
|
||||
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["project_slug"] = _projectSlug,
|
||||
["exception"] = exception.GetType().FullName,
|
||||
["message"] = exception.Message,
|
||||
["stack_trace"] = Truncate(exception.ToString(), MaxStackTraceLength),
|
||||
["level"] = fatal ? "fatal" : "error",
|
||||
["build"] = _build,
|
||||
["environment"] = _environment,
|
||||
|
||||
// Idempotenz: Kommt derselbe Fehler nach einem Neustart erneut, erhöht der
|
||||
// Server den Zähler, statt einen zweiten Eintrag anzulegen.
|
||||
["client_ref"] = Fingerprint(exception)
|
||||
};
|
||||
|
||||
if (context is { Count: > 0 })
|
||||
payload["context"] = context;
|
||||
|
||||
if (exception.TargetSite?.DeclaringType?.FullName is { } declaringType)
|
||||
payload["file"] = declaringType;
|
||||
|
||||
try
|
||||
{
|
||||
await _api.PostAsync("/api/errors/v1/report", payload, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten:
|
||||
// Der ursprüngliche Fehler ginge dabei verloren.
|
||||
_logger.LogDebug(ex, "Fehlermeldung an das Deploymentcenter fehlgeschlagen (ignoriert).");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet.</summary>
|
||||
private bool ShouldSend(Exception exception)
|
||||
{
|
||||
var key = Fingerprint(exception);
|
||||
var now = _now();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastSent.TryGetValue(key, out var last) && now - last < RepeatWindow)
|
||||
return false;
|
||||
|
||||
// Alte Einträge räumen, damit das Wörterbuch bei wechselnden Fehlern nicht wächst.
|
||||
if (_lastSent.Count > 200)
|
||||
{
|
||||
foreach (var stale in _lastSent
|
||||
.Where(e => now - e.Value > RepeatWindow)
|
||||
.Select(e => e.Key)
|
||||
.ToList())
|
||||
{
|
||||
_lastSent.Remove(stale);
|
||||
}
|
||||
}
|
||||
|
||||
_lastSent[key] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kennzeichen eines Fehlers: Typ plus oberste Stelle im Stacktrace. Die Meldung
|
||||
/// bleibt bewusst außen vor — sie enthält oft wechselnde Werte (IDs, Pfade), und
|
||||
/// dann wäre jeder Aufruf ein neuer Fehler.
|
||||
/// </summary>
|
||||
private static string Fingerprint(Exception exception)
|
||||
{
|
||||
var frame = exception.StackTrace?
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault()?
|
||||
.Trim() ?? "";
|
||||
|
||||
return $"{exception.GetType().FullName}|{frame}";
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int max) =>
|
||||
value.Length <= max ? value : value[..max] + "\n… (gekürzt)";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsApi)
|
||||
_api.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Ein für diese Instanz ausgestelltes Sub-Token.</summary>
|
||||
/// <param name="Token">Der Klartext — wird nur einmal ausgeliefert.</param>
|
||||
/// <param name="TokenId">Kennung zum Widerrufen in der Verwaltung.</param>
|
||||
/// <param name="Scopes">Welche Rechte tatsächlich durchgereicht wurden.</param>
|
||||
public sealed record ProvisionedToken(string Token, string TokenId, IReadOnlyList<string> Scopes);
|
||||
|
||||
/// <summary>
|
||||
/// Tauscht das anwendungsweite Master-Token gegen ein eigenes Sub-Token je Instanz
|
||||
/// (<c>POST /api/tokens/v1/provision</c>).
|
||||
///
|
||||
/// <para>Das ersetzt die frühere Selbstregistrierung über <c>POST /api/register</c> —
|
||||
/// diesen Endpunkt gibt es im Deploymentcenter nicht (und im alten WatchDog-Server war
|
||||
/// er der einzige Weg, überhaupt an einen Token zu kommen). Der Zweck bleibt derselbe
|
||||
/// und ist es wert, erhalten zu bleiben: Auf den Instanzen liegt danach nicht das
|
||||
/// Master-Token, sondern ein eingeschränktes, einzeln widerrufbares.</para>
|
||||
///
|
||||
/// <para>Rechte lassen sich dabei nur einschränken, nie erweitern — was das
|
||||
/// Master-Token nicht hat, bekommt auch das Sub-Token nicht.</para>
|
||||
/// </summary>
|
||||
public sealed class TokenProvisioner(DeploymentcenterApi api)
|
||||
{
|
||||
/// <summary>Was eine Instanz braucht: Heartbeats senden und Fehler melden.</summary>
|
||||
public static readonly string[] InstanceScopes = ["watchdog:ping", "bugtracker:report"];
|
||||
|
||||
public async Task<ProvisionedToken> ProvisionAsync(
|
||||
string clientName,
|
||||
string instanceId,
|
||||
IReadOnlyList<string> scopes,
|
||||
string environment = "production",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
client_name = clientName,
|
||||
instance_id = instanceId,
|
||||
scopes,
|
||||
environment
|
||||
};
|
||||
|
||||
var response = await api.PostAsync("/api/tokens/v1/provision", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var token = response.TryGetProperty("sub_token", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"no_token",
|
||||
"Die Provisionierung lieferte kein Token.",
|
||||
System.Net.HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
var tokenId = response.TryGetProperty("token_id", out var i) && i.ValueKind == JsonValueKind.String
|
||||
? i.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
var granted = new List<string>();
|
||||
if (response.TryGetProperty("scopes", out var s) && s.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
granted.AddRange(s.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!));
|
||||
}
|
||||
|
||||
return new ProvisionedToken(token, tokenId, granted);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user