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,32 @@
using System.Net;
using System.Text;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
/// <summary>Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff.</summary>
internal sealed class CapturingHandler(
HttpStatusCode status = HttpStatusCode.OK,
string responseBody = """{"status":"success"}""")
: HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public int Calls { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
LastRequest = request;
if (request.Content is not null)
LastBody = await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(status)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
}
}
@@ -0,0 +1,69 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class DeploymentcenterApiTests
{
[Fact]
public async Task Token_geht_als_Bearer_mit()
{
var handler = new CapturingHandler();
using var api = new DeploymentcenterApi("https://dc.example", "tok-1", new HttpClient(handler));
await api.PostAsync("/api/health", new { }, default);
handler.LastRequest!.Headers.Authorization!.ToString().ShouldBe("Bearer tok-1");
}
[Fact]
public async Task Fehlerumschlag_wird_zu_einer_Ausnahme_mit_Code()
{
var handler = new CapturingHandler(HttpStatusCode.Unauthorized,
"""{"status":"error","error":{"code":"unauthorized","message":"Kein gueltiges Token."}}""");
using var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => api.PostAsync("/api/watchdog/v1/ping", new { }, default));
ex.Code.ShouldBe("unauthorized");
ex.IsAuthorizationProblem.ShouldBeTrue();
}
/// <summary>
/// Ein Fehlerumschlag zählt auch dann, wenn der Statuscode 200 lautet — der Server
/// antwortet nicht überall mit passendem HTTP-Code.
/// </summary>
[Fact]
public async Task Fehlerumschlag_mit_HTTP_200_zaehlt_trotzdem()
{
var handler = new CapturingHandler(HttpStatusCode.OK,
"""{"status":"error","error":{"code":"rate_limited","message":"Zu viele."}}""");
using var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => api.PostAsync("/x", new { }, default));
ex.Code.ShouldBe("rate_limited");
ex.IsAuthorizationProblem.ShouldBeFalse();
}
[Fact]
public void Nicht_HTTPS_wird_abgelehnt()
{
// Über eine ungesicherte Verbindung ginge das Token im Klartext.
Should.Throw<ArgumentException>(() => new DeploymentcenterApi("http://dc.example", "tok"));
}
[Fact]
public void Localhost_darf_auch_ohne_TLS()
{
// Eine lokale Testinstallation hat selten ein Zertifikat, und mithören kann
// auf dem eigenen Rechner niemand.
using var api = new DeploymentcenterApi("http://localhost:8080", "tok");
api.BaseUrl.ShouldBe("http://localhost:8080");
}
}
@@ -0,0 +1,97 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class ErrorReporterTests
{
private static (ErrorReporter Reporter, CapturingHandler Handler) Build(
Func<DateTimeOffset>? now = null,
HttpStatusCode status = HttpStatusCode.Created)
{
var handler = new CapturingHandler(status,
"""{"status":"success","item_id":7,"is_new":true,"occurrence_count":1}""");
var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
var reporter = new ErrorReporter(
api, "clawddotnet", "production", "0.1.0", NullLogger.Instance, ownsApi: true, now: now);
return (reporter, handler);
}
private static Exception Thrown(string message)
{
try { throw new InvalidOperationException(message); }
catch (Exception ex) { return ex; }
}
[Fact]
public async Task Meldung_geht_an_den_Fehler_Endpunkt()
{
var (reporter, handler) = Build();
using var _ = reporter;
var sent = await reporter.ReportAsync(Thrown("Kaputt"), fatal: true);
sent.ShouldBeTrue();
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/errors/v1/report");
handler.LastBody!.ShouldContain("\"exception\":\"System.InvalidOperationException\"");
handler.LastBody.ShouldContain("\"message\":\"Kaputt\"");
handler.LastBody.ShouldContain("\"level\":\"fatal\"");
handler.LastBody.ShouldContain("\"project_slug\":\"clawddotnet\"");
}
/// <summary>
/// Eine Fehlerschleife darf nicht in tausend Anfragen münden. Derselbe Fehler geht
/// höchstens einmal je Zeitfenster raus — der Server drosselt zwar auch, aber erst,
/// nachdem die Anfragen schon über die Leitung waren.
/// </summary>
[Fact]
public async Task Derselbe_Fehler_geht_nur_einmal_je_Zeitfenster_raus()
{
var now = DateTimeOffset.UtcNow;
var (reporter, handler) = Build(() => now);
using var _ = reporter;
var error = Thrown("Immer wieder");
(await reporter.ReportAsync(error)).ShouldBeTrue();
(await reporter.ReportAsync(error)).ShouldBeFalse();
(await reporter.ReportAsync(error)).ShouldBeFalse();
handler.Calls.ShouldBe(1);
}
[Fact]
public async Task Nach_dem_Zeitfenster_wird_wieder_gemeldet()
{
var now = DateTimeOffset.UtcNow;
var (reporter, handler) = Build(() => now);
using var _ = reporter;
var error = Thrown("Immer wieder");
await reporter.ReportAsync(error);
now = now.AddMinutes(6);
await reporter.ReportAsync(error);
handler.Calls.ShouldBe(2);
}
/// <summary>
/// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten — der
/// ursprüngliche Fehler ginge dabei verloren.
/// </summary>
[Fact]
public async Task Ein_abgelehnter_Aufruf_wirft_nicht_nach_aussen()
{
var (reporter, _) = Build(status: HttpStatusCode.Unauthorized);
using var __ = reporter;
var sent = await reporter.ReportAsync(Thrown("Egal"));
sent.ShouldBeFalse();
}
}
@@ -0,0 +1,107 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class InstanceHealthProviderTests
{
/// <summary>Liefert einen festen Tagesverbrauch, ohne DB.</summary>
private sealed class FakeUsage(DailyUsage daily) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> Task.FromResult(daily);
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct)
=> Task.FromResult((IReadOnlyList<RunUsage>)Array.Empty<RunUsage>());
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
private static DailyUsage Usage(decimal cost, int tokens = 0) =>
new(DateOnly.FromDateTime(DateTime.Now), tokens, 0, cost, true, 1);
[Fact]
public async Task Ohne_API_Key_meldet_error()
{
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: false, InstanceBudget.Unlimited,
usage: null, () => 2, () => 0);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Error);
health.Metrics["agentCount"].ShouldBe(2);
health.Checks["agents"].Ok.ShouldBeFalse();
}
[Fact]
public async Task Erschoepftes_Kostenbudget_meldet_warning()
{
var budget = new InstanceBudget { DailyCostUsd = 5m };
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: true, budget,
new FakeUsage(Usage(cost: 5m)), () => 1, () => 0);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Warning);
health.Checks["budget"].Ok.ShouldBeFalse();
health.Message.ShouldNotBeNull();
}
/// <summary>
/// Ein Heartbeat beweist nur, dass ein Faden läuft. Steht der Scanner, arbeitet die
/// Instanz nichts mehr ab — und genau das soll die Prüfung sichtbar machen.
/// </summary>
[Fact]
public async Task Stehender_Scanner_meldet_warning()
{
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: true, InstanceBudget.Unlimited,
new FakeUsage(Usage(cost: 0m)), () => 1, () => 0,
schedulerRunning: () => false);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Warning);
health.Checks["scheduler"].Ok.ShouldBeFalse();
}
[Fact]
public async Task Normalbetrieb_meldet_ok_mit_Metriken()
{
var provider = new InstanceHealthProvider(
"Test", agentsEnabled: true, InstanceBudget.Unlimited,
new FakeUsage(Usage(cost: 0.5m, tokens: 1200)), () => 3, () => 1,
schedulerRunning: () => true);
var health = await provider.GetAsync(default);
health.Status.ShouldBe(WatchdogStatus.Ok);
health.Metrics["runningChats"].ShouldBe(1);
health.Metrics.Keys.ShouldContain("todayCostUsd");
health.Metrics.Keys.ShouldContain("todayTokens");
health.Checks.Values.ShouldAllBe(c => c.Ok);
}
/// <summary>
/// Metriken werden serverseitig nur ausgewertet, wenn sie Zahlen sind — alles andere
/// verwirft der Verlauf stillschweigend. Beschreibendes gehört in die Meldung.
/// </summary>
[Fact]
public async Task Instanzname_steht_in_der_Meldung_nicht_in_den_Metriken()
{
var provider = new InstanceHealthProvider(
"Produktion", agentsEnabled: true, InstanceBudget.Unlimited,
usage: null, () => 1, () => 0);
var health = await provider.GetAsync(default);
health.Message.ShouldContain("Produktion");
health.Metrics.Keys.ShouldNotContain("instanceName");
}
}
@@ -0,0 +1,50 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class TokenProvisionerTests
{
[Fact]
public async Task Instanz_bekommt_ein_eigenes_Sub_Token()
{
var handler = new CapturingHandler(HttpStatusCode.Created,
"""
{"status":"success","sub_token":"dc_sub_abc","token_id":"tok_s_1",
"scopes":["watchdog:ping","bugtracker:report"]}
""");
using var api = new DeploymentcenterApi("https://dc.example", "master", new HttpClient(handler));
var token = await new TokenProvisioner(api).ProvisionAsync(
"ClawdDotNet Produktion", "inst-1", TokenProvisioner.InstanceScopes);
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/tokens/v1/provision");
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer master");
handler.LastBody!.ShouldContain("\"instance_id\":\"inst-1\"");
handler.LastBody.ShouldContain("watchdog:ping");
token.Token.ShouldBe("dc_sub_abc");
token.TokenId.ShouldBe("tok_s_1");
token.Scopes.ShouldContain("bugtracker:report");
}
/// <summary>
/// Ein Sub-Token darf keine weiteren ausstellen. Das ist der Regelfall beim zweiten
/// Start und muss als sauberer Fehler herauskommen, nicht als Absturz.
/// </summary>
[Fact]
public async Task Abgelehnte_Provisionierung_wirft_mit_Code()
{
var handler = new CapturingHandler(HttpStatusCode.Forbidden,
"""{"status":"error","error":{"code":"provision_denied","message":"Kein Master-Token."}}""");
using var api = new DeploymentcenterApi("https://dc.example", "sub", new HttpClient(handler));
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => new TokenProvisioner(api).ProvisionAsync("x", "inst-1", ["watchdog:ping"]));
ex.Code.ShouldBe("provision_denied");
}
}
@@ -0,0 +1,137 @@
using System.Net;
using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class WatchdogClientTests
{
private static WatchdogClient Client(HttpMessageHandler handler, string instance = "inst-1") =>
WatchdogClient.Create("https://dc.example", "tok", "clawddotnet", instance,
"ClawdDotNet", "Windows / .NET 10", "0.1.0", new HttpClient(handler));
private static InstanceHealth Health(
string status = WatchdogStatus.Ok,
string? message = null,
Dictionary<string, double>? metrics = null,
Dictionary<string, HealthCheck>? checks = null)
=> new(status, message, metrics ?? [], checks ?? []);
[Fact]
public async Task Heartbeat_geht_an_den_v1_Pfad_mit_Bearer_Token()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(Health(message: "Betrieb normal."), 60, default);
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/ping");
handler.LastRequest.Method.ShouldBe(HttpMethod.Post);
handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer tok");
handler.LastBody.ShouldNotBeNull();
handler.LastBody.ShouldContain("\"source\":\"clawddotnet\"");
handler.LastBody.ShouldContain("\"instance\":\"inst-1\"");
handler.LastBody.ShouldContain("\"type\":\"heartbeat\"");
handler.LastBody.ShouldContain("\"interval\":60");
}
/// <summary>
/// Der Server legt die Version in <c>watchdog_monitors.app_version</c> ab. Bei
/// mehreren Instanzen ist das der Unterschied zwischen „läuft" und „läuft noch auf
/// der alten Fassung".
/// </summary>
[Fact]
public async Task Heartbeat_traegt_die_Anwendungsversion()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(Health(), 60, default);
handler.LastBody!.ShouldContain("\"version\":\"0.1.0\"");
}
/// <summary>
/// Der Monitor wird serverseitig über source + instance geführt. Zwei Instanzen
/// derselben Anwendung müssen deshalb unterschiedliche instance-Werte melden —
/// sonst überschreiben sie sich gegenseitig, und der Ausfall einer bliebe unsichtbar.
/// </summary>
[Fact]
public async Task Jede_Instanz_meldet_unter_eigenem_Instanznamen()
{
var first = new CapturingHandler();
var second = new CapturingHandler();
await Client(first, "inst-a").SendHeartbeatAsync(Health(), 60, default);
await Client(second, "inst-b").SendHeartbeatAsync(Health(), 60, default);
first.LastBody!.ShouldContain("\"instance\":\"inst-a\"");
second.LastBody!.ShouldContain("\"instance\":\"inst-b\"");
}
[Fact]
public async Task Checks_und_Metriken_gehen_mit()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(
Health(
metrics: new Dictionary<string, double> { ["agentCount"] = 3 },
checks: new Dictionary<string, HealthCheck>
{
["scheduler"] = new(false, "Steht.")
}),
60, default);
handler.LastBody!.ShouldContain("\"agentCount\":3");
handler.LastBody.ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}");
}
/// <summary>Leere Angaben bleiben weg, statt als leeres Objekt zu erscheinen.</summary>
[Fact]
public async Task Ohne_Checks_und_Metriken_bleiben_die_Felder_weg()
{
var handler = new CapturingHandler();
await Client(handler).SendHeartbeatAsync(Health(), 60, default);
handler.LastBody!.ShouldNotContain("\"checks\"");
handler.LastBody.ShouldNotContain("\"metrics\"");
}
[Fact]
public async Task Antwort_liefert_Zustand_und_fehlgeschlagene_Pruefungen()
{
var handler = new CapturingHandler(HttpStatusCode.OK,
"""{"status":"success","monitor":{"state":"warning","failing_checks":["scheduler"]}}""");
var result = await Client(handler).SendHeartbeatAsync(Health(), 60, default);
result.State.ShouldBe("warning");
result.FailingChecks.ShouldHaveSingleItem().ShouldBe("scheduler");
}
[Fact]
public async Task Ereignis_geht_an_den_Event_Endpunkt()
{
var handler = new CapturingHandler();
await Client(handler).SendEventAsync(
WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, default);
handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/event");
handler.LastBody!.ShouldContain("\"kind\":\"stopped_graceful\"");
}
[Fact]
public async Task Abgelehntes_Token_wirft_mit_Code()
{
var handler = new CapturingHandler(HttpStatusCode.Unauthorized,
"""{"status":"error","error":{"code":"unauthorized","message":"Kein gueltiges Token."}}""");
var ex = await Should.ThrowAsync<DeploymentcenterException>(
() => Client(handler).SendHeartbeatAsync(Health(), 60, default));
ex.Code.ShouldBe("unauthorized");
}
}