feat: improve agent management and speed up tests
This commit is contained in:
1 parent
134c06d605
commit
193fc3a95c
28 files changed
+419
-57
No files matched your search
+30
-4
@@ -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",
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class AgentEndpoints
|
||||
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(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<object>(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<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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <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>
|
||||
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 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>
|
||||
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 <node-name>";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, command));
|
||||
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
// 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<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;
|
||||
|
||||
/// <summary>
|
||||
/// 测试方案里「固定时长」阶段(没有 <c>duration_s</c> 参数的,例如 connect、hold)的缩放比例,缺省 1。
|
||||
/// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。
|
||||
/// </summary>
|
||||
public double PhaseDurationScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ public interface IJoinTokenStore
|
||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IProbeStore
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace Monitor.Backend.Application;
|
||||
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
|
||||
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<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))
|
||||
{
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string>() == "udp_train") == agent.App.Counters.Fragments,
|
||||
|
||||
@@ -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<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);
|
||||
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
|
||||
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]
|
||||
|
||||
@@ -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<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());
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
@@ -78,7 +78,7 @@ public sealed class ProbeEndToEndTests
|
||||
TimeSpan.FromSeconds(90));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>线路质量 · 管理</title>
|
||||
<title>MyMonitor · 管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -38,7 +38,7 @@ function Login() {
|
||||
<div className="login">
|
||||
<form className="card stack" onSubmit={submit}>
|
||||
<div>
|
||||
<h1>线路质量</h1>
|
||||
<h1>MyMonitor</h1>
|
||||
<p className="muted">管理端</p>
|
||||
</div>
|
||||
{remote ? (
|
||||
@@ -88,7 +88,7 @@ export function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<a className="brand" href="/">
|
||||
线路质量
|
||||
MyMonitor
|
||||
</a>
|
||||
{NAV.map((group) => (
|
||||
<div key={group.section} style={{ display: 'contents' }}>
|
||||
|
||||
@@ -69,6 +69,27 @@
|
||||
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 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import '@shared/theme/base.css'
|
||||
import { configureSource } from '@shared/api/source'
|
||||
import { applyStoredTheme } from '@shared/hooks'
|
||||
import { ToastProvider } from '@shared/ui'
|
||||
import { ErrorBoundary, ToastProvider } from '@shared/ui'
|
||||
import { App } from './App'
|
||||
import { AuthProvider } from './auth'
|
||||
import './admin.css'
|
||||
@@ -15,11 +15,13 @@ applyStoredTheme()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Ban, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
|
||||
import { fmtAgo, fmtTime } from '@shared/format'
|
||||
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' }> = {
|
||||
active: { label: '已启用', tone: 'ok' },
|
||||
@@ -45,11 +45,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
>
|
||||
<div className="stack">
|
||||
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
|
||||
<Field label="在要加入的 VPS 上运行">
|
||||
<pre className="code">{created.install_command}</pre>
|
||||
<div>
|
||||
<CopyButton text={created.install_command} label="复制安装命令" />
|
||||
</div>
|
||||
<Field label="在要加入的机器上运行">
|
||||
<CommandTabs
|
||||
storageKey="monitor.install_tab"
|
||||
initial={guessPlatform()}
|
||||
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 label="令牌">
|
||||
<pre className="code">{created.plaintext}</pre>
|
||||
@@ -135,6 +140,7 @@ export function Agents() {
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [revoking, setRevoking] = useState<JoinTokenDto>()
|
||||
const [deleting, setDeleting] = useState<JoinTokenDto>()
|
||||
const { run } = useAction()
|
||||
|
||||
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>
|
||||
</td>
|
||||
<td className="r">
|
||||
{state === '可用' ? (
|
||||
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
|
||||
<span className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
|
||||
{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} />
|
||||
吊销
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -271,6 +283,20 @@ export function Agents() {
|
||||
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
|
||||
</Confirm>
|
||||
) : 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,9 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
|
||||
server: {
|
||||
port: 5174,
|
||||
// 5174 常被 Docker Desktop 占用,管理端用 5175;端口被占时直接报错,不悄悄换到别的端口
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
fs: { allow: ['..'] },
|
||||
proxy: {
|
||||
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
|
||||
+46
-3
@@ -1,7 +1,50 @@
|
||||
// 同时启动展示端(5173)和管理端(5174/admin/);Ctrl+C 一起退出。
|
||||
// 同时启动展示端(5173)和管理端(5175,经展示端的 /admin/ 访问);Ctrl+C 一起退出。
|
||||
// 不经过 shell:直接用 node 跑各自目录下的 vite,输出带前缀,端口被占时直接报错。
|
||||
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 stop = () => children.forEach((c) => c.kill())
|
||||
const vite = fileURLToPath(new URL('./node_modules/vite/bin/vite.js', import.meta.url))
|
||||
|
||||
// 颜色:和 Vite 自己的输出一致(绿色的 ➜、青色/蓝色的链接);设了 NO_COLOR 就不上色
|
||||
const color = !process.env.NO_COLOR
|
||||
const paint = (code, text) => (color ? `[${code}m${text}[0m` : 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('exit', stop)
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>线路质量</title>
|
||||
<title>MyMonitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function App() {
|
||||
<header className="topbar">
|
||||
<div className="inner">
|
||||
<NavLink to="/" className="brand">
|
||||
线路质量
|
||||
MyMonitor
|
||||
</NavLink>
|
||||
<nav className="topnav">
|
||||
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import '@shared/theme/base.css'
|
||||
import { configureSource } from '@shared/api/source'
|
||||
import { applyStoredTheme } from '@shared/hooks'
|
||||
import { ToastProvider } from '@shared/ui'
|
||||
import { ErrorBoundary, ToastProvider } from '@shared/ui'
|
||||
import { App } from './App'
|
||||
import './display.css'
|
||||
|
||||
@@ -14,9 +14,11 @@ applyStoredTheme()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -146,7 +146,7 @@ export function Overview() {
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<h1>线路质量</h1>
|
||||
<h1>MyMonitor</h1>
|
||||
<p className="sub">
|
||||
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
|
||||
@@ -13,8 +13,11 @@ export default defineConfig(({ mode }) => {
|
||||
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
fs: { allow: ['..'] },
|
||||
proxy: {
|
||||
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
|
||||
'/admin': { target: 'http://localhost:5175', changeOrigin: true, ws: true },
|
||||
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
},
|
||||
|
||||
@@ -442,7 +442,7 @@ export interface paths {
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Revoke a join token */
|
||||
/** Revoke a join token; with purge=true delete the record for good */
|
||||
delete: operations["join_tokens_revoke"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -986,7 +986,7 @@ export interface components {
|
||||
CreatedJoinTokenDto: {
|
||||
token: components["schemas"]["JoinTokenDto"];
|
||||
plaintext: string;
|
||||
install_command: string;
|
||||
install_commands: components["schemas"]["InstallCommandsDto"];
|
||||
};
|
||||
DimensionDto: {
|
||||
id: string;
|
||||
@@ -1112,6 +1112,11 @@ export interface components {
|
||||
[key: string]: (null | number | string)[];
|
||||
};
|
||||
};
|
||||
InstallCommandsDto: {
|
||||
linux: string;
|
||||
macos: string;
|
||||
windows: string;
|
||||
};
|
||||
JoinTokenDto: {
|
||||
id: string;
|
||||
note: null | string;
|
||||
@@ -2902,7 +2907,9 @@ export interface operations {
|
||||
};
|
||||
join_tokens_revoke: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
purge?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
|
||||
@@ -700,3 +700,83 @@ pre.code {
|
||||
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
@@ -1,5 +1,5 @@
|
||||
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 { getBase, getToken, normalizeBase, setSource } from '../api/source'
|
||||
import { useTheme } from '../hooks'
|
||||
@@ -230,6 +230,116 @@ export function LoadingBar({ active }: { active: boolean }) {
|
||||
) : 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 令牌,后端要放行这个前端的来源)。 */
|
||||
export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
Reference in new issue
Block a user