feat: improve agent management and speed up tests

This commit is contained in:
justme committed 2026-10-06 15:48:46 +08:00
1 parent 134c06d605
commit 193fc3a95c
28 files changed
+419 -57

No files matched your search

+30 -4
View File
@@ -1770,7 +1770,7 @@
"tags": [ "tags": [
"admin-join-tokens" "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", "operationId": "join_tokens_revoke",
"parameters": [ "parameters": [
{ {
@@ -1780,6 +1780,13 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "purge",
"in": "query",
"schema": {
"type": "boolean"
}
} }
], ],
"responses": { "responses": {
@@ -4345,7 +4352,7 @@
"required": [ "required": [
"token", "token",
"plaintext", "plaintext",
"install_command" "install_commands"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4355,8 +4362,8 @@
"plaintext": { "plaintext": {
"type": "string" "type": "string"
}, },
"install_command": { "install_commands": {
"type": "string" "$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": { "JoinTokenDto": {
"required": [ "required": [
"id", "id",
@@ -43,7 +43,7 @@ public static class AgentEndpoints
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag) .Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); .Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) 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<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403); .Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
group.MapGet("/probes", ListProbesAsync) group.MapGet("/probes", ListProbesAsync)
@@ -130,12 +130,20 @@ public static class AgentEndpoints
http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/join-tokens"; 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<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct) private static async Task<IResult> 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(); return TypedResults.NoContent();
} }
@@ -46,7 +46,9 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours); public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
/// <summary>令牌明文只在创建的响应里出现一次。</summary> /// <summary>令牌明文只在创建的响应里出现一次。</summary>
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);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary> /// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince); public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince);
@@ -4,7 +4,10 @@ namespace Monitor.Backend.Application;
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours); public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand); /// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary> /// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
public sealed class JoinTokenService public sealed class JoinTokenService
@@ -55,11 +58,16 @@ public sealed class JoinTokenService
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct); request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
string command = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>"; string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, command)); // PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
} }
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct); public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
/// <summary>彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。</summary>
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct);
} }
@@ -55,6 +55,12 @@ public sealed class BackendOptions
public int MaxCompareAgents { get; set; } = 20; public int MaxCompareAgents { get; set; } = 20;
/// <summary>
/// 测试方案里「固定时长」阶段(没有 <c>duration_s</c> 参数的,例如 connect、hold)的缩放比例,缺省 1。
/// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。
/// </summary>
public double PhaseDurationScale { get; set; } = 1.0;
/// <summary>初始管理员(只在没有任何用户时创建)。</summary> /// <summary>初始管理员(只在没有任何用户时创建)。</summary>
public string? InitialAdminUser { get; set; } public string? InitialAdminUser { get; set; }
@@ -40,6 +40,8 @@ public interface IJoinTokenStore
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct); ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct); ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
} }
public interface IProbeStore public interface IProbeStore
@@ -9,6 +9,7 @@ namespace Monitor.Backend.Application;
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary> /// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
public sealed class TestService public sealed class TestService
{ {
private readonly double _phaseScale;
private readonly ITestStore _tests; private readonly ITestStore _tests;
private readonly IAgentStore _agents; private readonly IAgentStore _agents;
private readonly AgentRuntime _runtime; private readonly AgentRuntime _runtime;
@@ -16,8 +17,9 @@ public sealed class TestService
private readonly LiveHub _live; private readonly LiveHub _live;
private readonly TimeProvider _time; 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; _tests = tests;
_agents = agents; _agents = agents;
_runtime = runtime; _runtime = runtime;
@@ -52,7 +54,7 @@ public sealed class TestService
return Result<TestRecord>.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs"); return Result<TestRecord>.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs");
} }
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent); Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent, _phaseScale);
if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error)) if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error))
{ {
return Result<TestRecord>.Fail(error); return Result<TestRecord>.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; public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted;
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent) private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent, double phaseScale)
{ {
if (overrides is not null) if (overrides is not null)
{ {
@@ -245,7 +247,7 @@ public sealed class TestService
return Result<List<TestPhase>>.Fail(MonitorErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s"); return Result<List<TestPhase>>.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<string, JsonElement>(resolved.Values.Values), DurationSeconds = duration }); phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary<string, JsonElement>(resolved.Values.Values), DurationSeconds = duration });
} }
@@ -34,6 +34,9 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
public ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct) => public ValueTask<bool> 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); _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<bool> 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 public sealed class SqliteProbeStore : IProbeStore
@@ -256,7 +256,7 @@ public sealed class AgentEndToEndTests
string? startFailure = null; string? startFailure = null;
for (int attempt = 0; attempt < 5; attempt++) 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) if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted)
{ {
break; break;
@@ -291,7 +291,7 @@ public sealed class AgentEndToEndTests
Assert.Equal(4, probe.Phases.Count); Assert.Equal(4, probe.Phases.Count);
Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind)); 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); Assert.True(agent.App.Counters.Fragments > 4);
await BackendFixture.EventuallyAsync( await BackendFixture.EventuallyAsync(
async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == "udp_train") == agent.App.Counters.Fragments, async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == "udp_train") == agent.App.Counters.Fragments,
@@ -192,6 +192,7 @@ public class BackendFixture : IAsyncLifetime
options.InitialAdminUser = AdminUser; options.InitialAdminUser = AdminUser;
options.InitialAdminPassword = AdminPassword; options.InitialAdminPassword = AdminPassword;
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
}); });
await App.StartAsync(); await App.StartAsync();
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First(); string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
@@ -83,6 +83,14 @@ public sealed class EnrollmentAndTaskSetTests
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id); using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id);
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
await Assert.ThrowsAsync<InvalidOperationException>(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked"))); await Assert.ThrowsAsync<InvalidOperationException>(() => 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<string>() == 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<string>() == id);
} }
[Fact] [Fact]
@@ -64,7 +64,7 @@ public sealed class ProbeEndToEndTests
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>()); await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
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()); Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>(); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
@@ -78,7 +78,7 @@ public sealed class ProbeEndToEndTests
TimeSpan.FromSeconds(90)); TimeSpan.FromSeconds(90));
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString()); Assert.True(test["status"]!.GetValue<string>() == "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); Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
} }
+2 -2
View File
@@ -10,9 +10,9 @@
```sh ```sh
npm install # 在 web/ 下一次装好两个工程的依赖(npm workspaces) 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: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 # 或者把 .env.example 复制成 .env.local,写好 BACKEND_URL,之后直接 npm run dev:display
npm run build # 各自输出到 display/dist 和 admin/dist npm run build # 各自输出到 display/dist 和 admin/dist
npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>线路质量 · 管理</title> <title>MyMonitor · 管理</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -38,7 +38,7 @@ function Login() {
<div className="login"> <div className="login">
<form className="card stack" onSubmit={submit}> <form className="card stack" onSubmit={submit}>
<div> <div>
<h1>线路质量</h1> <h1>MyMonitor</h1>
<p className="muted">管理端</p> <p className="muted">管理端</p>
</div> </div>
{remote ? ( {remote ? (
@@ -88,7 +88,7 @@ export function App() {
<div className="shell"> <div className="shell">
<aside className="side"> <aside className="side">
<a className="brand" href="/"> <a className="brand" href="/">
线路质量 MyMonitor
</a> </a>
{NAV.map((group) => ( {NAV.map((group) => (
<div key={group.section} style={{ display: 'contents' }}> <div key={group.section} style={{ display: 'contents' }}>
+21
View File
@@ -69,6 +69,27 @@
background: var(--app-card); background: var(--app-card);
} }
/* 用户名太长时省略,不要和右边的按钮叠在一起 */
.me {
flex-wrap: wrap;
}
.me .grow {
flex: 1 1 100px;
min-width: 0;
}
.me .grow > div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.me .btn,
.me .avatar {
flex: none;
}
.avatar { .avatar {
width: 32px; width: 32px;
height: 32px; height: 32px;
+8 -6
View File
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import '@shared/theme/base.css' import '@shared/theme/base.css'
import { configureSource } from '@shared/api/source' import { configureSource } from '@shared/api/source'
import { applyStoredTheme } from '@shared/hooks' import { applyStoredTheme } from '@shared/hooks'
import { ToastProvider } from '@shared/ui' import { ErrorBoundary, ToastProvider } from '@shared/ui'
import { App } from './App' import { App } from './App'
import { AuthProvider } from './auth' import { AuthProvider } from './auth'
import './admin.css' import './admin.css'
@@ -15,11 +15,13 @@ applyStoredTheme()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}> <BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider> <ErrorBoundary>
<AuthProvider> <ToastProvider>
<App /> <AuthProvider>
</AuthProvider> <App />
</ToastProvider> </AuthProvider>
</ToastProvider>
</ErrorBoundary>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
) )
+37 -11
View File
@@ -1,11 +1,11 @@
import { Plus, Trash2 } from 'lucide-react' import { Ban, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '@shared/api/client' import { api } from '@shared/api/client'
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types' import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format' import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks' import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, guessPlatform, Segmented, Spinner, useAction } from '@shared/ui'
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = { export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
active: { label: '已启用', tone: 'ok' }, active: { label: '已启用', tone: 'ok' },
@@ -45,11 +45,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
> >
<div className="stack"> <div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div> <div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<Field label="在要加入的 VPS 上运行"> <Field label="在要加入的机器上运行">
<pre className="code">{created.install_command}</pre> <CommandTabs
<div> storageKey="monitor.install_tab"
<CopyButton text={created.install_command} label="复制安装命令" /> initial={guessPlatform()}
</div> tabs={[
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
]}
/>
</Field> </Field>
<Field label="令牌"> <Field label="令牌">
<pre className="code">{created.plaintext}</pre> <pre className="code">{created.plaintext}</pre>
@@ -135,6 +140,7 @@ export function Agents() {
const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all') const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all')
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [revoking, setRevoking] = useState<JoinTokenDto>() const [revoking, setRevoking] = useState<JoinTokenDto>()
const [deleting, setDeleting] = useState<JoinTokenDto>()
const { run } = useAction() const { run } = useAction()
const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter) const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter)
@@ -240,12 +246,18 @@ export function Agents() {
<Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip> <Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip>
</td> </td>
<td className="r"> <td className="r">
{state === '可用' ? ( <span className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}> {state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Ban size={14} />
吊销
</button>
) : null}
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} /> <Trash2 size={14} />
吊销 删除
</button> </button>
) : null} </span>
</td> </td>
</tr> </tr>
) )
@@ -271,6 +283,20 @@ export function Agents() {
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。 吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
</Confirm> </Confirm>
) : null} ) : null}
{deleting ? (
<Confirm
title="删除加入令牌"
danger
confirmText="删除"
onClose={() => setDeleting(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/join-tokens/${deleting.id}?purge=true`), '已删除')
tokens.reload()
}}
>
从列表里彻底删除这条记录。{deleting.revoked_at || deleting.uses >= deleting.max_uses || Date.parse(deleting.expires_at) < Date.now() ? '它已经不能再用了。' : '它现在还能用,删除之后会立刻失效。'}已经注册的 Agent 不受影响。
</Confirm>
) : null}
</> </>
) )
} }
+3 -1
View File
@@ -12,7 +12,9 @@ export default defineConfig(({ mode }) => {
plugins: [react()], plugins: [react()],
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } }, resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
server: { server: {
port: 5174, // 5174 常被 Docker Desktop 占用,管理端用 5175;端口被占时直接报错,不悄悄换到别的端口
port: 5175,
strictPort: true,
fs: { allow: ['..'] }, fs: { allow: ['..'] },
proxy: { proxy: {
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' }, '/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
+46 -3
View File
@@ -1,7 +1,50 @@
// 同时启动展示端(5173)和管理端(5174/admin/);Ctrl+C 一起退出。 // 同时启动展示端(5173)和管理端(5175,经展示端的 /admin/ 访问);Ctrl+C 一起退出。
// 不经过 shell:直接用 node 跑各自目录下的 vite,输出带前缀,端口被占时直接报错。
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const children = ['display', 'admin'].map((name) => spawn('npm', ['run', 'dev', '-w', name], { stdio: 'inherit', shell: true })) const vite = fileURLToPath(new URL('./node_modules/vite/bin/vite.js', import.meta.url))
const stop = () => children.forEach((c) => c.kill())
// 颜色:和 Vite 自己的输出一致(绿色的 ➜、青色/蓝色的链接);设了 NO_COLOR 就不上色
const color = !process.env.NO_COLOR
const paint = (code, text) => (color ? `[${code}m${text}` : text)
const bold = (t) => paint('1', t)
const dim = (t) => paint('2', t)
const green = (t) => paint('32', t)
const cyan = (t) => paint('36', t)
const magenta = (t) => paint('35', t)
const link = (url) => paint('36;4', url)
const apps = [
{ name: 'display', tint: cyan, cwd: fileURLToPath(new URL('./display/', import.meta.url)) },
{ name: 'admin ', tint: magenta, cwd: fileURLToPath(new URL('./admin/', import.meta.url)) },
]
console.log('')
console.log(` ${green('➜')} ${bold('展示端')} ${link('http://localhost:5173/')}`)
console.log(` ${green('➜')} ${bold('管理端')} ${link('http://localhost:5173/admin/')} ${dim('(也可以直接开 http://localhost:5175/admin/)')}`)
console.log('')
const children = apps.map(({ name, tint, cwd }) => {
const child = spawn(process.execPath, [vite], { cwd, stdio: ['inherit', 'pipe', 'pipe'], env: { ...process.env, ...(color ? { FORCE_COLOR: '1' } : {}) } })
const prefix = (stream, out) =>
stream.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) if (line.trim()) out.write(`${tint(`[${name}]`)} ${line}\n`)
})
prefix(child.stdout, process.stdout)
prefix(child.stderr, process.stderr)
child.on('exit', (code) => {
if (code) {
console.error(`${tint(`[${name}]`)} ${paint('31', `退出了(${code}),一起停掉`)}`)
stop()
process.exitCode = code
}
})
return child
})
function stop() {
for (const c of children) c.kill()
}
process.on('SIGINT', stop) process.on('SIGINT', stop)
process.on('exit', stop) process.on('exit', stop)
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>线路质量</title> <title>MyMonitor</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -10,7 +10,7 @@ export function App() {
<header className="topbar"> <header className="topbar">
<div className="inner"> <div className="inner">
<NavLink to="/" className="brand"> <NavLink to="/" className="brand">
线路质量 MyMonitor
</NavLink> </NavLink>
<nav className="topnav"> <nav className="topnav">
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}> <NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
+6 -4
View File
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import '@shared/theme/base.css' import '@shared/theme/base.css'
import { configureSource } from '@shared/api/source' import { configureSource } from '@shared/api/source'
import { applyStoredTheme } from '@shared/hooks' import { applyStoredTheme } from '@shared/hooks'
import { ToastProvider } from '@shared/ui' import { ErrorBoundary, ToastProvider } from '@shared/ui'
import { App } from './App' import { App } from './App'
import './display.css' import './display.css'
@@ -14,9 +14,11 @@ applyStoredTheme()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}> <BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider> <ErrorBoundary>
<App /> <ToastProvider>
</ToastProvider> <App />
</ToastProvider>
</ErrorBoundary>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
) )
+1 -1
View File
@@ -146,7 +146,7 @@ export function Overview() {
return ( return (
<main className="page"> <main className="page">
<section className="hero"> <section className="hero">
<h1>线路质量</h1> <h1>MyMonitor</h1>
<p className="sub"> <p className="sub">
{data ? `${online} / ${cards.length} 台在线` : '加载中…'} {data ? `${online} / ${cards.length} 台在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} /> <span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
+3
View File
@@ -13,8 +13,11 @@ export default defineConfig(({ mode }) => {
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } }, resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
server: { server: {
port: 5173, port: 5173,
strictPort: true,
fs: { allow: ['..'] }, fs: { allow: ['..'] },
proxy: { proxy: {
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
'/admin': { target: 'http://localhost:5175', changeOrigin: true, ws: true },
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' }, '/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' }, '/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
}, },
+10 -3
View File
@@ -442,7 +442,7 @@ export interface paths {
get?: never; get?: never;
put?: never; put?: never;
post?: never; post?: never;
/** Revoke a join token */ /** Revoke a join token; with purge=true delete the record for good */
delete: operations["join_tokens_revoke"]; delete: operations["join_tokens_revoke"];
options?: never; options?: never;
head?: never; head?: never;
@@ -986,7 +986,7 @@ export interface components {
CreatedJoinTokenDto: { CreatedJoinTokenDto: {
token: components["schemas"]["JoinTokenDto"]; token: components["schemas"]["JoinTokenDto"];
plaintext: string; plaintext: string;
install_command: string; install_commands: components["schemas"]["InstallCommandsDto"];
}; };
DimensionDto: { DimensionDto: {
id: string; id: string;
@@ -1112,6 +1112,11 @@ export interface components {
[key: string]: (null | number | string)[]; [key: string]: (null | number | string)[];
}; };
}; };
InstallCommandsDto: {
linux: string;
macos: string;
windows: string;
};
JoinTokenDto: { JoinTokenDto: {
id: string; id: string;
note: null | string; note: null | string;
@@ -2902,7 +2907,9 @@ export interface operations {
}; };
join_tokens_revoke: { join_tokens_revoke: {
parameters: { parameters: {
query?: never; query?: {
purge?: boolean;
};
header?: never; header?: never;
path: { path: {
id: string; id: string;
+80
View File
@@ -700,3 +700,83 @@ pre.code {
width: 100%; width: 100%;
} }
} }
/* ── 带标签切换的命令框 ── */
.cmd-tabs {
border-radius: var(--r-control);
background: var(--app-card-inset);
overflow: hidden;
}
.cmd-tablist {
display: flex;
gap: 4px;
padding: 0 12px;
border-bottom: 1px solid var(--app-outline);
}
.cmd-tablist button {
position: relative;
padding: 10px 10px 9px;
border: 0;
background: transparent;
color: var(--app-ink-faint);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.cmd-tablist button:hover {
color: var(--app-ink);
}
.cmd-tablist button.on {
color: var(--app-ink);
font-weight: 600;
}
.cmd-tablist button.on::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
bottom: -1px;
height: 2px;
border-radius: 1px;
background: var(--app-ink);
}
.cmd-body {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 12px 12px 14px;
}
.cmd-body code {
flex: 1;
min-width: 0;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12.5px;
line-height: 1.6;
word-break: break-all;
user-select: all;
}
.cmd-copy {
flex: none;
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--app-ink-faint);
cursor: pointer;
}
.cmd-copy:hover {
background: var(--app-card-hover);
color: var(--app-ink);
}
+111 -1
View File
@@ -1,5 +1,5 @@
import { Check, Copy, Moon, Plug, Sun } from 'lucide-react' import { Check, Copy, Moon, Plug, Sun } from 'lucide-react'
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { getBase, getToken, normalizeBase, setSource } from '../api/source' import { getBase, getToken, normalizeBase, setSource } from '../api/source'
import { useTheme } from '../hooks' import { useTheme } from '../hooks'
@@ -230,6 +230,116 @@ export function LoadingBar({ active }: { active: boolean }) {
) : null ) : null
} }
export interface CommandTab {
id: string
label: string
command: string
}
/** 按浏览器所在的系统挑一个默认的标签(Windows / macOS / 其他当 Linux)。 */
export function guessPlatform(): 'windows' | 'macos' | 'linux' {
const text = `${navigator.platform} ${navigator.userAgent}`.toLowerCase()
return text.includes('win') ? 'windows' : text.includes('mac') ? 'macos' : 'linux'
}
/**
* 带标签切换的命令框(curl / npm / bun 那样):上面一排下划线标签,下面是等宽的命令,右侧一个复制图标。
* 记住上次选的标签(只是浏览器里的小方便)。
*/
export function CommandTabs({ tabs, initial, storageKey }: { tabs: CommandTab[]; initial?: string; storageKey?: string }) {
const [active, setActive] = useState(() => {
let saved: string | null = null
try {
saved = storageKey ? localStorage.getItem(storageKey) : null
} catch {
// 浏览器禁用存储时只是不记住
}
return [saved, initial].find((id) => id && tabs.some((t) => t.id === id)) ?? tabs[0]?.id ?? ''
})
const [done, setDone] = useState(false)
const tab = tabs.find((t) => t.id === active) ?? tabs[0]
if (!tab) return null
const pick = (id: string) => {
setActive(id)
try {
if (storageKey) localStorage.setItem(storageKey, id)
} catch {
// 同上
}
}
return (
<div className="cmd-tabs">
<div className="cmd-tablist" role="tablist">
{tabs.map((t) => (
<button key={t.id} type="button" role="tab" aria-selected={t.id === tab.id} className={t.id === tab.id ? 'on' : ''} onClick={() => pick(t.id)}>
{t.label}
</button>
))}
</div>
<div className="cmd-body">
<code>{tab.command}</code>
<button
type="button"
className="cmd-copy"
aria-label="复制命令"
title="复制命令"
onClick={async () => {
try {
await navigator.clipboard.writeText(tab.command)
setDone(true)
window.setTimeout(() => setDone(false), 1500)
} catch {
// 没有剪贴板权限时用户可以手动选中
}
}}
>
{done ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
</div>
)
}
/**
* 页面出错时显示提示,而不是整页白屏:说明出了什么错,可以重试(重新渲染这一块)或刷新整个页面。
* 常见原因是前端和后端版本对不上(后端还没升级到新接口)。
*/
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(error, info.componentStack)
}
render() {
const { error } = this.state
if (!error) return this.props.children
return (
<div className="login">
<div className="card stack" style={{ maxWidth: 520 }}>
<div>
<h1>页面出错了</h1>
<p className="muted">这一页没能显示出来。如果刚升级过,多半是前端和后端的版本对不上:先把后端升级到对应的版本,再刷新。</p>
</div>
<pre className="code">{error.message}</pre>
<div className="row" style={{ gap: 8 }}>
<button type="button" className="btn primary" onClick={() => this.setState({ error: null })}>
重试
</button>
<button type="button" className="btn" onClick={() => location.reload()}>
刷新页面
</button>
</div>
</div>
</div>
)
}
}
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */ /** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */
export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) { export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)