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