From 193fc3a95cc2599fc682d35f372b385467f1c80a Mon Sep 17 00:00:00 2001 From: JustMe Date: Tue, 6 Oct 2026 15:48:46 +0800 Subject: [PATCH] feat: improve agent management and speed up tests --- backend/openapi/v1.json | 34 +++++- .../Admin/AgentEndpoints.cs | 16 ++- .../Contracts/AdminDtos.cs | 4 +- .../Admin/JoinTokenService.cs | 14 ++- .../BackendOptions.cs | 6 + .../Ports/StorePorts.cs | 2 + .../Tests/TestService.cs | 10 +- .../SqliteConfigStores.cs | 3 + .../AgentEndToEndTests.cs | 4 +- .../Monitor.Backend.Tests/BackendFixture.cs | 1 + .../EnrollmentAndTaskSetTests.cs | 8 ++ .../ProbeEndToEndTests.cs | 4 +- web/README.md | 4 +- web/admin/index.html | 2 +- web/admin/src/App.tsx | 4 +- web/admin/src/admin.css | 21 ++++ web/admin/src/main.tsx | 14 ++- web/admin/src/pages/Agents.tsx | 48 ++++++-- web/admin/vite.config.ts | 4 +- web/dev.mjs | 49 +++++++- web/display/index.html | 2 +- web/display/src/App.tsx | 2 +- web/display/src/main.tsx | 10 +- web/display/src/pages/Overview.tsx | 2 +- web/display/vite.config.ts | 3 + web/shared/api/schema.ts | 13 +- web/shared/theme/base.css | 80 +++++++++++++ web/shared/ui/index.tsx | 112 +++++++++++++++++- 28 files changed, 419 insertions(+), 57 deletions(-) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index bcc200e..a1a7aa6 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1770,7 +1770,7 @@ "tags": [ "admin-join-tokens" ], - "summary": "Revoke a join token", + "summary": "Revoke a join token; with purge=true delete the record for good", "operationId": "join_tokens_revoke", "parameters": [ { @@ -1780,6 +1780,13 @@ "schema": { "type": "string" } + }, + { + "name": "purge", + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -4345,7 +4352,7 @@ "required": [ "token", "plaintext", - "install_command" + "install_commands" ], "type": "object", "properties": { @@ -4355,8 +4362,8 @@ "plaintext": { "type": "string" }, - "install_command": { - "type": "string" + "install_commands": { + "$ref": "#/components/schemas/InstallCommandsDto" } } }, @@ -4908,6 +4915,25 @@ } } }, + "InstallCommandsDto": { + "required": [ + "linux", + "macos", + "windows" + ], + "type": "object", + "properties": { + "linux": { + "type": "string" + }, + "macos": { + "type": "string" + }, + "windows": { + "type": "string" + } + } + }, "JoinTokenDto": { "required": [ "id", diff --git a/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs index e0f20c2..a4f0d18 100644 --- a/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs @@ -43,7 +43,7 @@ public static class AgentEndpoints .Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) - .Api("join_tokens_revoke", "Revoke a join token", TokensTag) + .Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); group.MapGet("/probes", ListProbesAsync) @@ -130,12 +130,20 @@ public static class AgentEndpoints http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.Location = "/api/admin/join-tokens"; - return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, created.InstallCommand), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); + return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); } - private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct) + private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct) { - await tokens.RevokeAsync(id, ct); + if (purge == true) + { + await tokens.DeleteAsync(id, ct); + } + else + { + await tokens.RevokeAsync(id, ct); + } + return TypedResults.NoContent(); } diff --git a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs index 96fffa0..cc1bf64 100644 --- a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs @@ -46,7 +46,9 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours); /// 令牌明文只在创建的响应里出现一次。 -public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, string InstallCommand); +public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); + +public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); /// Agent 眼中连着的一个 Probe(Agent 上报)。 public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince); diff --git a/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs b/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs index 658e13d..813bca0 100644 --- a/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs @@ -4,7 +4,10 @@ namespace Monitor.Backend.Application; public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours); -public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand); +/// 各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。 +public sealed record InstallCommands(string Linux, string Macos, string Windows); + +public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands); /// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 public sealed class JoinTokenService @@ -55,11 +58,16 @@ public sealed class JoinTokenService request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct); string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); - string command = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name "; - return Result.Ok(new CreatedJoinToken(info, plaintext, command)); + string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name "; + // PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴 + string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name \""; + return Result.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell))); } public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); public ValueTask RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + + /// 彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。 + public ValueTask DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct); } diff --git a/backend/src/Monitor.Backend.Application/BackendOptions.cs b/backend/src/Monitor.Backend.Application/BackendOptions.cs index ded13ac..55e908f 100644 --- a/backend/src/Monitor.Backend.Application/BackendOptions.cs +++ b/backend/src/Monitor.Backend.Application/BackendOptions.cs @@ -55,6 +55,12 @@ public sealed class BackendOptions public int MaxCompareAgents { get; set; } = 20; + /// + /// 测试方案里「固定时长」阶段(没有 duration_s 参数的,例如 connect、hold)的缩放比例,缺省 1。 + /// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。 + /// + public double PhaseDurationScale { get; set; } = 1.0; + /// 初始管理员(只在没有任何用户时创建)。 public string? InitialAdminUser { get; set; } diff --git a/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs b/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs index 2b81e70..dc460e8 100644 --- a/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs @@ -40,6 +40,8 @@ public interface IJoinTokenStore ValueTask> ListAsync(CancellationToken ct); ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct); + + ValueTask DeleteAsync(string id, CancellationToken ct); } public interface IProbeStore diff --git a/backend/src/Monitor.Backend.Application/Tests/TestService.cs b/backend/src/Monitor.Backend.Application/Tests/TestService.cs index e489309..d0dfaad 100644 --- a/backend/src/Monitor.Backend.Application/Tests/TestService.cs +++ b/backend/src/Monitor.Backend.Application/Tests/TestService.cs @@ -9,6 +9,7 @@ namespace Monitor.Backend.Application; /// 按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。 public sealed class TestService { + private readonly double _phaseScale; private readonly ITestStore _tests; private readonly IAgentStore _agents; private readonly AgentRuntime _runtime; @@ -16,8 +17,9 @@ public sealed class TestService private readonly LiveHub _live; private readonly TimeProvider _time; - public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time) + public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time, BackendOptions options) { + _phaseScale = options.PhaseDurationScale; _tests = tests; _agents = agents; _runtime = runtime; @@ -52,7 +54,7 @@ public sealed class TestService return Result.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs"); } - Result> phases = BuildPhases(plan, request.Params, agent); + Result> phases = BuildPhases(plan, request.Params, agent, _phaseScale); if (!phases.TryGet(out List? phaseList, out FleetError? error)) { return Result.Fail(error); @@ -173,7 +175,7 @@ public sealed class TestService public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted; - private static Result> BuildPhases(PlanSpec plan, IReadOnlyDictionary? overrides, AgentRecord agent) + private static Result> BuildPhases(PlanSpec plan, IReadOnlyDictionary? overrides, AgentRecord agent, double phaseScale) { if (overrides is not null) { @@ -245,7 +247,7 @@ public sealed class TestService return Result>.Fail(MonitorErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s"); } - int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : phase.DurationSeconds; + int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : Math.Max(1, (int)Math.Round(phase.DurationSeconds * phaseScale)); phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary(resolved.Values.Values), DurationSeconds = duration }); } diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs index 83656f0..96e0f55 100644 --- a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -34,6 +34,9 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore public ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct) => _db.WriteAsync(c => c.Exec("UPDATE join_tokens SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); + + public ValueTask DeleteAsync(string id, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM join_tokens WHERE id = @id", ("@id", id)) == 1, ct); } public sealed class SqliteProbeStore : IProbeStore diff --git a/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs index cc4ae75..423762d 100644 --- a/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs +++ b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs @@ -256,7 +256,7 @@ public sealed class AgentEndToEndTests string? startFailure = null; for (int attempt = 0; attempt < 5; attempt++) { - accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); + accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } })); if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted) { break; @@ -291,7 +291,7 @@ public sealed class AgentEndToEndTests Assert.Equal(4, probe.Phases.Count); Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind)); - // 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(10 s、1000 B 的包)各被 Probe 切成多个列块 + // 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(5 s、1000 B 的包)里 100 Mbit/s 的那一段有 6 万多条记录,被 Probe 切成两个列块 Assert.True(agent.App.Counters.Fragments > 4); await BackendFixture.EventuallyAsync( async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue() == "udp_train") == agent.App.Counters.Fragments, diff --git a/backend/tests/Monitor.Backend.Tests/BackendFixture.cs b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs index ea0d71e..590ea4b 100644 --- a/backend/tests/Monitor.Backend.Tests/BackendFixture.cs +++ b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs @@ -192,6 +192,7 @@ public class BackendFixture : IAsyncLifetime options.InitialAdminUser = AdminUser; options.InitialAdminPassword = AdminPassword; options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; + options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒 }); await App.StartAsync(); string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); diff --git a/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs b/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs index 446346d..e383a97 100644 --- a/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs +++ b/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs @@ -83,6 +83,14 @@ public sealed class EnrollmentAndTaskSetTests using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id); Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); await Assert.ThrowsAsync(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked"))); + + // 吊销之后记录还在(显示「已吊销」);purge=true 才是彻底删除 + tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); + Assert.Contains(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue() == id); + using HttpResponseMessage purge = await _backend.Admin.DeleteAsync($"/api/admin/join-tokens/{id}?purge=true"); + Assert.Equal(HttpStatusCode.NoContent, purge.StatusCode); + tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); + Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue() == id); } [Fact] diff --git a/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs index d5f1b85..2bc74b1 100644 --- a/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs +++ b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs @@ -64,7 +64,7 @@ public sealed class ProbeEndToEndTests await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } })); Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); @@ -78,7 +78,7 @@ public sealed class ProbeEndToEndTests TimeSpan.FromSeconds(90)); Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); - await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "50 and 100 Mbit/s phases are split into several fragments"); + await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments"); Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); } diff --git a/web/README.md b/web/README.md index d15f158..a111d32 100644 --- a/web/README.md +++ b/web/README.md @@ -10,9 +10,9 @@ ```sh npm install # 在 web/ 下一次装好两个工程的依赖(npm workspaces) -npm run dev # 两个一起启动 +npm run dev # 两个一起启动:展示端 http://localhost:5173/ ,管理端 http://localhost:5173/admin/(也可以直接开 5175) BACKEND_URL=https://mo.oeo.one npm run dev:display # http://127.0.0.1:5173/ -BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5174/admin/ +BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5175/admin/ # 或者把 .env.example 复制成 .env.local,写好 BACKEND_URL,之后直接 npm run dev:display npm run build # 各自输出到 display/dist 和 admin/dist npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts diff --git a/web/admin/index.html b/web/admin/index.html index bad5a5c..20cdccb 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -3,7 +3,7 @@ - 线路质量 · 管理 + MyMonitor · 管理
diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index e241a18..a3244f2 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -38,7 +38,7 @@ function Login() {
-

线路质量

+

MyMonitor

管理端

{remote ? ( @@ -88,7 +88,7 @@ export function App() {