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": [
|
"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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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}[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('SIGINT', stop)
|
||||||
process.on('exit', stop)
|
process.on('exit', stop)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' : '')}>
|
||||||
|
|||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
@@ -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 ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user