diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 92d1667..c647fec 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -3901,6 +3901,136 @@ } } }, + "/api/admin/links": { + "get": { + "tags": [ + "admin-links" + ], + "summary": "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", + "operationId": "links_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkMatrixDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/links/{probe_id}/{agent_id}": { + "put": { + "tags": [ + "admin-links" + ], + "summary": "Force a link on or off, or give it back to the automatic setting (enabled: null)", + "operationId": "links_set", + "parameters": [ + { + "name": "probe_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/healthz": { "get": { "tags": [ @@ -5418,6 +5548,62 @@ } } }, + "LinkDto": { + "required": [ + "probe_id", + "probe_name", + "agent_id", + "agent_name", + "linked", + "override", + "connected" + ], + "type": "object", + "properties": { + "probe_id": { + "type": "string" + }, + "probe_name": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "agent_name": { + "type": "string" + }, + "linked": { + "type": "boolean" + }, + "override": { + "type": [ + "null", + "boolean" + ] + }, + "connected": { + "type": "boolean" + } + } + }, + "LinkMatrixDto": { + "required": [ + "auto_link", + "items" + ], + "type": "object", + "properties": { + "auto_link": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LinkDto" + } + } + } + }, "LocalLimitsDto": { "required": [ "max_total_mbps", @@ -7013,6 +7199,20 @@ } } }, + "SetLinkRequest": { + "required": [ + "enabled" + ], + "type": "object", + "properties": { + "enabled": { + "type": [ + "null", + "boolean" + ] + } + } + }, "SettingDto": { "required": [ "key", @@ -7451,6 +7651,9 @@ { "name": "admin-users" }, + { + "name": "admin-links" + }, { "name": "ops" }, diff --git a/backend/src/Monitor.Backend.Api/Admin/LinkEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/LinkEndpoints.cs new file mode 100644 index 0000000..5737f62 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Admin/LinkEndpoints.cs @@ -0,0 +1,59 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 线路开关:哪些(Probe,Agent)要连。自动连接的总开关是设置 fleet.auto_link;这里看每一对的结果,并且手动强制开、关或取消手动设置。 +public static class LinkEndpoints +{ + private const string Tag = "admin-links"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/links", ListAsync) + .Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns().ProducesProblems(401, 403); + group.MapPut("/links/{probe_id}/{agent_id}", SetAsync) + .Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns().ProducesProblems(400, 401, 403, 404); + } + + private static async Task ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct) + { + LinkSnapshot snapshot = await links.SnapshotAsync(ct); + IReadOnlyList agentViews = await agents.ListAsync(ct); + var items = new List(); + foreach (ProbeView probe in (await probes.ListAsync(ct)).Where(p => !p.Revoked).OrderBy(p => p.Probe.Name, StringComparer.Ordinal)) + { + foreach (AgentView agent in agentViews.OrderBy(a => a.Agent.Name, StringComparer.Ordinal)) + { + items.Add(ToDto(snapshot, probe, agent)); + } + } + + return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, items), ApiJsonContext.Default.LinkMatrixDto); + } + + private static async Task SetAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, string probe_id, string agent_id, SetLinkRequest request, CancellationToken ct) + { + if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe) + { + return Problems.Create(http, MonitorErrors.ProbeNotFound, "probe not found"); + } + + if (await agents.GetAsync(new NodeId(agent_id), ct) is not { } agent) + { + return Problems.Create(http, MonitorErrors.AgentNotFound, "agent not found"); + } + + await links.SetAsync(probe_id, agent_id, request.Enabled, ct); + await agents.AfterAgentSetChangedAsync(ct); // 重新下发:Probe 的 Agent 列表、Agent 允许的 Probe + return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), probe, agent), ApiJsonContext.Default.LinkDto); + } + + private static LinkDto ToDto(LinkSnapshot snapshot, ProbeView probe, AgentView agent) => + new(probe.Probe.Id, probe.Probe.Name, agent.Agent.Id.Value, agent.Agent.Name, snapshot.IsLinked(probe.Probe.Id, agent.Agent.Id.Value), snapshot.OverrideOf(probe.Probe.Id, agent.Agent.Id.Value), + agent.Probes.Any(p => p.Id == probe.Probe.Id)); +} diff --git a/backend/src/Monitor.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/SystemEndpoints.cs index 957199b..d833295 100644 --- a/backend/src/Monitor.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/Monitor.Backend.Api/Admin/SystemEndpoints.cs @@ -63,9 +63,14 @@ public static class SystemEndpoints private static async Task ListSettingsAsync(SettingsService settings, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await settings.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto); - private static async Task PatchSettingsAsync(HttpContext http, SettingsService settings, Dictionary changes) + private static async Task PatchSettingsAsync(HttpContext http, SettingsService settings, AgentService agents, Dictionary changes) { Result> result = await settings.PatchAsync(changes, http.RequestAborted); + if (result.IsSuccess && changes.ContainsKey(SettingsService.AutoLink)) + { + await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 Probe 的 Agent 列表和各 Agent 允许的 Probe + } + return result.TryGet(out IReadOnlyList? views, out FleetError? error) ? TypedResults.Json(new PageDto([.. views.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto) : Problems.From(http, error); diff --git a/backend/src/Monitor.Backend.Api/ApiModule.cs b/backend/src/Monitor.Backend.Api/ApiModule.cs index 8ced47e..bbab80b 100644 --- a/backend/src/Monitor.Backend.Api/ApiModule.cs +++ b/backend/src/Monitor.Backend.Api/ApiModule.cs @@ -112,6 +112,7 @@ public static class ApiModule DataEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); + LinkEndpoints.Map(guarded); OpsEndpoints.Map(app); app.MapOpenApi("/openapi/v1.json"); diff --git a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs index 8814760..45236ab 100644 --- a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs @@ -59,6 +59,13 @@ public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool A public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections); +/// 一对(Probe,Agent)的线路开关:linked 是最终结果,override 是手动设置(null = 跟随自动连接),connected 是现在有没有连着。 +public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected); + +public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList Items); + +public sealed record SetLinkRequest(bool? Enabled); + public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps); /// probe.json:含私钥,只在生成时返回一次。 diff --git a/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs index 810701f..8a355b9 100644 --- a/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs @@ -37,6 +37,9 @@ namespace Monitor.Backend.Api; [JsonSerializable(typeof(AgentDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(AgentPatchRequest))] +[JsonSerializable(typeof(LinkDto))] +[JsonSerializable(typeof(LinkMatrixDto))] +[JsonSerializable(typeof(SetLinkRequest))] [JsonSerializable(typeof(BuildDto))] [JsonSerializable(typeof(ReleaseDto))] [JsonSerializable(typeof(PageDto))] diff --git a/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs b/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs index 566f91b..eb05808 100644 --- a/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs +++ b/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs @@ -13,6 +13,7 @@ public sealed class SettingsService { public const string SitePublic = "site.public"; public const string SiteShowBuild = "site.show_build"; + public const string AutoLink = "fleet.auto_link"; public const string RawDays = "retention.raw_days"; public const string TestRawDays = "retention.test_raw_days"; public const string Metric10sHours = "retention.metric_10s_hours"; @@ -42,6 +43,7 @@ public sealed class SettingsService [ new(SitePublic, "bool", "false", 0, 1, "Whether anonymous visitors can read the public API."), new(SiteShowBuild, "bool", "false", 0, 1, "Whether the public pages show the versions, tags and build times of the backend, agents and probes."), + new(AutoLink, "bool", "true", 0, 1, "Whether every probe connects to every agent automatically; when off, only the links chosen by hand are connected."), new(RawDays, "int", o.RawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of continuous tasks."), new(TestRawDays, "int", o.TestRawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of on-demand tests."), new(Metric10sHours, "int", o.Metric10sRetentionHours.ToString(CultureInfo.InvariantCulture), 1, 720, "Hours to keep the 10 s metric layer."), diff --git a/backend/src/Monitor.Backend.Application/Agents/AgentService.cs b/backend/src/Monitor.Backend.Application/Agents/AgentService.cs index 61550f5..8c95cc2 100644 --- a/backend/src/Monitor.Backend.Application/Agents/AgentService.cs +++ b/backend/src/Monitor.Backend.Application/Agents/AgentService.cs @@ -21,9 +21,11 @@ public sealed partial class AgentService private readonly ISettingsStore _settings; private readonly NodeInfoService _nodes; + private readonly LinkService _links; - public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes) + public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links) { + _links = links; _nodes = nodes; _agents = agents; _runtime = runtime; @@ -118,17 +120,19 @@ public sealed partial class AgentService await _gateway.DisconnectAsync(id, "deleted", ct); await _agents.DeleteAsync(id, ct); await _nodes.ForgetAsync(id.Value, ct); + await _links.ForgetNodeAsync(id.Value, ct); _runtime.Remove(id); await AfterAgentSetChangedAsync(ct); } - /// Probe 需要连接的 Agent 列表(所有已激活的 Agent)。版本号在列表变化时递增。 - public async ValueTask ProbeAgentsAsync(CancellationToken ct) + /// 这个 Probe 要连接的 Agent 列表(已激活、有公开地址,并且线路是开着的)。版本号在列表变化时递增。 + public async ValueTask ProbeAgentsAsync(string probeId, CancellationToken ct) { + LinkSnapshot links = await _links.SnapshotAsync(ct); IReadOnlyList agents = await _agents.ListAsync(ct); long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0; var result = new ProbeAgentList { Version = version }; - foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0)) + foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && links.IsLinked(probeId, a.Id.Value))) { result.Agents.Add(new AgentEndpoint { diff --git a/backend/src/Monitor.Backend.Application/Agents/LinkService.cs b/backend/src/Monitor.Backend.Application/Agents/LinkService.cs new file mode 100644 index 0000000..63347d9 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Agents/LinkService.cs @@ -0,0 +1,68 @@ +namespace Monitor.Backend.Application; + +/// 某一时刻的「线路」开关:自动连接的总开关,加上每一对(Probe,Agent)的手动设置。 +public sealed class LinkSnapshot +{ + private readonly Dictionary<(string Probe, string Agent), bool> _overrides; + + public LinkSnapshot(bool auto, Dictionary<(string Probe, string Agent), bool> overrides) + { + Auto = auto; + _overrides = overrides; + } + + /// 自动连接:开着时,没有手动设置的 Probe 和 Agent 都会连;关着时,只有手动开启的才连。 + public bool Auto { get; } + + /// 手动设置:true 强制连、false 强制不连、null 没设置(跟随自动连接)。 + public bool? OverrideOf(string probeId, string agentId) => _overrides.TryGetValue((probeId, agentId), out bool value) ? value : null; + + /// 这一对最终连不连:手动设置优先,没有就看自动连接。 + public bool IsLinked(string probeId, string agentId) => OverrideOf(probeId, agentId) ?? Auto; +} + +/// +/// 哪些(Probe,Agent)要连成线路。由面板决定、Probe 主动拨出:自动连接开着时所有 Probe 和 Agent 两两连(默认), +/// 关着时只连手动选中的;每一对还可以手动强制开或关。手动设置存在设置表里(link.<probe>.<agent>,值 1 或 0,空表示没设置)。 +/// 判断结果分别用在 Probe 的 Agent 列表、Agent 允许的 Probe(peers)和线路目录里;改动之后要调用 重新下发。 +/// +public sealed class LinkService +{ + private const string Prefix = "link."; + + private readonly ISettingsStore _settings; + + public LinkService(ISettingsStore settings) + { + _settings = settings; + } + + public async ValueTask SnapshotAsync(CancellationToken ct) + { + IReadOnlyDictionary all = await _settings.GetAllAsync(ct); + var overrides = new Dictionary<(string, string), bool>(); + foreach ((string key, string value) in all) + { + string[] parts = key.Split('.'); + if (parts.Length == 3 && parts[0] + "." == Prefix && value is "1" or "0") + { + overrides[(parts[1], parts[2])] = value == "1"; + } + } + + return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides); + } + + /// 手动设置一对线路:true 强制连、false 强制不连、null 取消手动设置。 + public ValueTask SetAsync(string probeId, string agentId, bool? enabled, CancellationToken ct) => + _settings.SetAsync($"{Prefix}{probeId}.{agentId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct); + + /// Probe 或 Agent 被删除:清掉所有涉及它的手动设置。 + public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct) + { + foreach (string key in (await _settings.GetAllAsync(ct)).Keys.Where(k => k.StartsWith(Prefix, StringComparison.Ordinal) && k.Split('.').Skip(1).Contains(nodeId))) + { + await _settings.SetAsync(key, "", ct); + } + } +} diff --git a/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs b/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs index c5db8cf..8b24e6c 100644 --- a/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs @@ -33,9 +33,11 @@ public sealed partial class ProbeService private readonly IAgentGateway _gateway; private readonly TimeProvider _time; private readonly NodeInfoService _nodes; + private readonly LinkService _links; - public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes) + public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links) { + _links = links; _nodes = nodes; _probes = probes; _agents = agents; @@ -137,6 +139,7 @@ public sealed partial class ProbeService if (deleted) { await _nodes.ForgetAsync(id, ct); + await _links.ForgetNodeAsync(id, ct); _gateway.NotifyAllTaskSetsChanged(); } diff --git a/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs index 462d334..2ad95a9 100644 --- a/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/Monitor.Backend.Application/Query/Lines.cs b/backend/src/Monitor.Backend.Application/Query/Lines.cs index 800c5db..53240c1 100644 --- a/backend/src/Monitor.Backend.Application/Query/Lines.cs +++ b/backend/src/Monitor.Backend.Application/Query/Lines.cs @@ -39,19 +39,22 @@ public sealed class LineService { private readonly AgentService _agents; private readonly IProbeStore _probes; + private readonly LinkService _links; - public LineService(AgentService agents, IProbeStore probes) + public LineService(AgentService agents, IProbeStore probes, LinkService links) { + _links = links; _agents = agents; _probes = probes; } - /// 所有线路:每个 Agent 和每个未吊销的 Probe 配成一对。 + /// 所有线路:每个 Agent 和每个未吊销的 Probe 配成一对,只留下线路开着的(见 );关掉的线路历史数据还在,按 id 仍然查得到。 public async ValueTask> ListAsync(Func include, CancellationToken ct) { IReadOnlyList agents = await _agents.ListAsync(ct); IReadOnlyList probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)]; - return [.. agents.Where(include).SelectMany(a => probes.Select(p => new LineView(a, p)))]; + LinkSnapshot links = await _links.SnapshotAsync(ct); + return [.. agents.Where(include).SelectMany(a => probes.Where(p => links.IsLinked(p.Id, a.Agent.Id.Value)).Select(p => new LineView(a, p)))]; } /// diff --git a/backend/src/Monitor.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/Monitor.Backend.Application/Tasks/TaskPlanner.cs index 11ee07d..526bb98 100644 --- a/backend/src/Monitor.Backend.Application/Tasks/TaskPlanner.cs +++ b/backend/src/Monitor.Backend.Application/Tasks/TaskPlanner.cs @@ -20,10 +20,12 @@ public sealed class TaskPlanner : IDisposable private readonly IAgentStore _agents; private readonly IProbeStore _probes; private readonly ISettingsStore _settings; + private readonly LinkService _links; private readonly SemaphoreSlim _gate = new(1, 1); - public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings) + public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links) { + _links = links; _tasks = tasks; _agents = agents; _probes = probes; @@ -41,6 +43,7 @@ public sealed class TaskPlanner : IDisposable IReadOnlyList all = await _tasks.ListAsync(ct); IReadOnlyList probes = await _probes.ListAsync(ct); + LinkSnapshot links = await _links.SnapshotAsync(ct); var document = new TaskSet(); foreach (EffectiveTask task in EffectiveTasks.Compute(all, agent, filterByAgentKinds: true)) @@ -57,7 +60,7 @@ public sealed class TaskPlanner : IDisposable }); } - foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null)) + foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && links.IsLinked(p.Id, agentId.Value))) { document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PublicKey) }); } diff --git a/backend/src/Monitor.Backend.Gateway/ProbeAgentsStateProvider.cs b/backend/src/Monitor.Backend.Gateway/ProbeAgentsStateProvider.cs index e832faf..e3f95de 100644 --- a/backend/src/Monitor.Backend.Gateway/ProbeAgentsStateProvider.cs +++ b/backend/src/Monitor.Backend.Gateway/ProbeAgentsStateProvider.cs @@ -25,7 +25,7 @@ public sealed class ProbeAgentsStateProvider : IStateProvider return null; } - ProbeAgentList list = await _agents.ProbeAgentsAsync(ct); + ProbeAgentList list = await _agents.ProbeAgentsAsync(node.Id.Value, ct); return new StateDocument(list.Version, JsonSerializer.SerializeToElement(list, MonitorJsonContext.Default.ProbeAgentList)); } diff --git a/backend/tests/Monitor.Backend.Tests/LinkTests.cs b/backend/tests/Monitor.Backend.Tests/LinkTests.cs new file mode 100644 index 0000000..5877c34 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/LinkTests.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Text.Json.Nodes; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +[Collection("probe-e2e")] +public sealed class LinkTests +{ + private readonly BackendFixture _backend; + + public LinkTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task SetLinkAsync(string probe, string agent, bool? enabled) => + await _backend.Admin.PutAsync($"/api/admin/links/{probe}/{agent}", BackendFixture.Json(new JsonObject { ["enabled"] = enabled })); + + private async Task SetAutoLinkAsync(bool on) + { + using HttpResponseMessage response = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["fleet.auto_link"] = on }, "application/merge-patch+json")); + response.EnsureSuccessStatusCode(); + } + + private async Task LinkAsync(string probe, string agent) => + (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["probe_id"]!.GetValue() == probe && i["agent_id"]!.GetValue() == agent); + + [Fact] + public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue(); + string agentId = agent.NodeId.Value; + try + { + // 自动连接(默认开):新 Probe 自动进入这个 Agent 允许的名单 + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe)); + JsonNode link = (await LinkAsync(probe, agentId))!; + Assert.True(link["linked"]!.GetValue()); + Assert.Null(link["override"]); + + // 手动关掉这一对:自动连接开着也不连 + using HttpResponseMessage off = await SetLinkAsync(probe, agentId, false); + Assert.Equal(HttpStatusCode.OK, off.StatusCode); + await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe)); + Assert.False((await LinkAsync(probe, agentId))!["linked"]!.GetValue()); + + // 取消手动设置:回到自动连接 + using HttpResponseMessage reset = await SetLinkAsync(probe, agentId, null); + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe)); + + // 关掉自动连接:没有手动选中的就都不连 + await SetAutoLinkAsync(false); + await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe)); + Assert.False((await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["auto_link"]!.GetValue()); + + // 自动连接关着,手动选中的照样连 + await SetLinkAsync(probe, agentId, true); + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe)); + Assert.True((await LinkAsync(probe, agentId))!["override"]!.GetValue()); + } + finally + { + await SetLinkAsync(probe, agentId, null); + await SetAutoLinkAsync(true); // 共用的后端:恢复默认,别影响别的测试 + } + } + + [Fact] + public async Task Links_RefuseUnknownNodes() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue(); + using HttpResponseMessage noProbe = await SetLinkAsync("prb_missing", agent.NodeId.Value, false); + Assert.Equal(HttpStatusCode.NotFound, noProbe.StatusCode); + using HttpResponseMessage noAgent = await SetLinkAsync(probe, "agt_missing", false); + Assert.Equal(HttpStatusCode.NotFound, noAgent.StatusCode); + } +} diff --git a/docs/api.md b/docs/api.md index e39dcb0..0a3badd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -137,6 +137,8 @@ | GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期等 | | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) | | GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,面板不验签、原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 | +| GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` | +| PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 | | POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) | | GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 | diff --git a/docs/enrollment.md b/docs/enrollment.md index dbf043a..e05757a 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -55,6 +55,7 @@ Probe 启动 ``` - 每个 Agent 通过自己的期望状态(`TaskSet.peers`)获得 Probe 的公钥,用来验证点对点会话的签名。 +- **哪些 Probe 连哪些 Agent 由面板决定,始终是 Probe 主动拨出**:设置 `fleet.auto_link`(默认开)打开时,所有未吊销的 Probe 和所有 Agent 两两连;关闭时只连手动选中的。每一对还可以在管理端 Probe 页的「线路」矩阵里手动强制开或关(`PUT /api/admin/links/{probe_id}/{agent_id}`),手动设置优先于自动连接。结果同时用在 Probe 的 Agent 列表、Agent 的 `peers` 和线路目录(关掉的线路从总览里消失,历史数据还在)。 - 吊销 Probe:在面板上重新生成身份,旧公钥立即从所有 Agent 的期望状态中移除,旧会话随之断开。 ## 4. Agent 本地配置(`/etc/monitor/agent.json`,面板无法修改) diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index 852b229..33d2dad 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -2,12 +2,89 @@ import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-reac import { useState } from 'react' import { Link } from 'react-router-dom' import { api } from '@shared/api/client' -import type { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types' +import type { LinkDto, LinkMatrixDto, Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types' import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' +/** + * 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的; + * 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。Probe 始终是主动拨出的一方。 + */ +function LinkMatrix() { + const matrix = useQuery(() => api.get('/api/admin/links'), [], 8000) + const { busy, run } = useAction() + const items = matrix.data?.items ?? [] + const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()] + const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()] + const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId) + const set = async (link: LinkDto, enabled: boolean | null) => { + await run(() => api.put(`/api/admin/links/${link.probe_id}/${link.agent_id}`, { enabled })) + matrix.reload() + } + const setAuto = async (on: boolean) => { + await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接') + matrix.reload() + } + if (items.length === 0 && !matrix.loading) return null + return ( +
+
+ 线路 + Probe 主动连接 Agent。点格子手动开关;「↺」回到自动。 + +
+ + + + + + {agents.map(([id, name]) => ( + + ))} + + + + {probes.map(([probeId, probeName]) => ( + + + {agents.map(([agentId]) => { + const link = cell(probeId, agentId) + if (!link) return + ) + })} + + ))} + +
Probe \ Agent{name}
{probeName} + const tone = link.connected ? 'ok' : link.linked ? 'warn' : '' + const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连' + return ( + + + + {link.override !== null && link.override !== undefined ? ( + <> + + 手动 + + + + ) : null} + +
+
+ ) +} + /** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */ function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) { const json = JSON.stringify(identity, null, 2) @@ -116,6 +193,7 @@ export function Probes() {
+ {probes.data && list.length === 0 ? (
还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。 diff --git a/web/display/src/display.css b/web/display/src/display.css index 7ae5cbb..420ed80 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -1205,19 +1205,11 @@ input { } /* 版本、tag:等宽的小字 */ -.fact .v.mono-tag, -.probe-tag { +.fact .v.mono-tag { font-family: var(--font-mono); font-size: 11px; } -.probe-tag { - padding: 0 6px; - border-radius: 6px; - background: color-mix(in srgb, var(--app-ink) 6%, transparent); - color: var(--app-ink-muted); -} - .build-info { display: flex; flex-wrap: wrap; @@ -1227,3 +1219,35 @@ input { font-family: var(--font-mono); font-size: 11px; } + +/* 总览卡片里的线路标题:名字太长就省略号,右边只放一个短标签,不换行 */ +.line-top { + min-width: 0; +} + +.line-top .line-name { + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; +} + +.line-top .line-name .arrow { + flex: none; +} + +.line-top .line-name .name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.line-top .line-name .flag, +.line-top .line-name .isp { + flex: none; +} + +.line-top .line-range, +.line-top .chip { + flex: none; + white-space: nowrap; +} diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index e20562a..e4410b1 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -95,18 +95,13 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem return (
- - → {infos.get(l.probe_id)?.country ? : null} - {l.probe_name} + + → + {infos.get(l.probe_id)?.country ? : null} + {l.probe_name} + {infos.get(l.probe_id)?.isp_key ? : null} - {infos.get(l.probe_id)?.isp_key ? : null} - {infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version ? ( - - {infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version} - - ) : null} - 近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range} - {!l.probe_connected && l.online ? Probe 未连接 : null} + {!l.probe_connected && l.online ? 未连接 : {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}}
diff --git a/web/shared/api/client.ts b/web/shared/api/client.ts index 78035d7..04d459d 100644 --- a/web/shared/api/client.ts +++ b/web/shared/api/client.ts @@ -83,5 +83,6 @@ export const api = { get: (path: string, query?: Query, init?: RequestInit) => request('GET', withQuery(path, query), undefined, init), post: (path: string, body?: unknown) => request('POST', path, body ?? {}), patch: (path: string, body: unknown) => request('PATCH', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), del: (path: string) => request('DELETE', path), } diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index eb8765f..a5175ff 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -851,6 +851,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Every probe/agent pair: whether it is linked, the manual override and whether it is connected */ + get: operations["links_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/links/{probe_id}/{agent_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Force a link on or off, or give it back to the automatic setting (enabled: null) */ + put: operations["links_set"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/healthz": { parameters: { query?: never; @@ -1250,6 +1284,19 @@ export interface components { kinds: string[]; last_test: null | components["schemas"]["TestSummaryDto"]; }; + LinkDto: { + probe_id: string; + probe_name: string; + agent_id: string; + agent_name: string; + linked: boolean; + override: null | boolean; + connected: boolean; + }; + LinkMatrixDto: { + auto_link: boolean; + items: components["schemas"]["LinkDto"][]; + }; LocalLimitsDto: { /** Format: double */ max_total_mbps: number | string; @@ -1594,6 +1641,9 @@ export interface components { [key: string]: string; }; }; + SetLinkRequest: { + enabled: null | boolean; + }; SettingDto: { key: string; type: string; @@ -4760,6 +4810,107 @@ export interface operations { }; }; }; + links_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LinkMatrixDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + links_set: { + parameters: { + query?: never; + header?: never; + path: { + probe_id: string; + agent_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetLinkRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LinkDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; health_get: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index f678e3c..d011294 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -19,6 +19,8 @@ export type BuildInfoDto = Narrow export type NodeBuildDto = Narrow export type NodeUpdateDto = Narrow export type ReleaseDto = Narrow +export type LinkDto = Narrow +export type LinkMatrixDto = Narrow export type OverviewDto = Narrow export type OverviewLineDto = Narrow export type SeriesDto = Narrow diff --git a/web/shared/i18n.ts b/web/shared/i18n.ts index 3f1047c..beb105f 100644 --- a/web/shared/i18n.ts +++ b/web/shared/i18n.ts @@ -143,6 +143,7 @@ export function translateCode(code: string): string | undefined { const SETTINGS: Record = { 'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)', + 'fleet.auto_link': '所有 Probe 自动连接所有 Agent(关闭后只连在「Probe → 线路」里手动选中的)', 'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent、Probe 的版本、tag 和构建时间', 'retention.raw_days': '持续任务的原始数据块保留多少天', 'retention.test_raw_days': '按需测试的原始数据块保留多少天',