feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user