diff --git a/AGENTS.md b/AGENTS.md index 5379688..56d3aee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Monitor is a self-hosted network path quality monitor. Backend coordinates Agent - Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request. - A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted. - Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit. -- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets. +- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests, `release_api_token` with the `release` scope). The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does. - Keep development OCI images in the `monitor-dev/` repository path and release images in `monitor/`. Do not let a development build overwrite release tags such as `latest`. ## Project layout diff --git a/backend/agent/README.md b/backend/agent/README.md index 42266c5..48fab1a 100644 --- a/backend/agent/README.md +++ b/backend/agent/README.md @@ -47,7 +47,7 @@ fleet-enroll --server https:// --join --name --ide monitor-agent --config /etc/monitor/agent.json ``` -也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-agent:`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取 `/etc/monitor/agent.json`,需要挂载配置与身份目录;建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。镜像以 UID/GID 1654 运行,挂载目录必须允许该用户读取配置和身份文件。 +容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-agent:`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取 `/etc/monitor/agent.json`,需要挂载配置与身份目录;建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。镜像以 UID/GID 1654 运行,挂载目录必须允许该用户读取配置和身份文件。 不传 `--config` 时默认读取 `/etc/monitor/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。 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/probe/README.md b/backend/probe/README.md index 07805f0..7adaed7 100644 --- a/backend/probe/README.md +++ b/backend/probe/README.md @@ -13,6 +13,8 @@ Backend ──任务──> Agent <──测量会话──> Probe Probe 使用面板生成的 `probe.json` 读取自身身份、面板地址(`server`)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。 +容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/monitor/monitor-probe:`,容器默认读取 `/etc/monitor/probe.json`,数据目录要允许 UID/GID 1654 读写。 + ## 构建 需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建: 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/deployment.md b/docs/deployment.md index fc50d6b..abe67d5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -195,6 +195,38 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。 +## 容器节点的运行(podman + systemd) + +Agent 和 Probe 推荐在宿主机上用 root 的 podman 加 systemd 运行,这样宿主机更新器(下一节)才能重启它们。以 Agent 为例(Probe 把名字和目录换成 `monitor-probe`,配置文件换成 `probe.json`): + +```ini +# /etc/systemd/system/monitor-agent-container.service +[Unit] +Description=Monitor Agent (podman) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/monitor-update/agent.image # IMAGE=<仓库>@sha256:,更新器更新时改写它 +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/podman rm -f monitor-agent +ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --cap-drop all -v /etc/os-release:/etc/os-release:ro -v /opt/monitor/etc:/etc/monitor:Z ${IMAGE} +ExecStop=/usr/bin/podman stop -t 20 monitor-agent + +[Install] +WantedBy=multi-user.target +``` + +- **数据目录**(上面挂到容器 `/etc/monitor` 的 `/opt/monitor/etc`)归容器用户(UID/GID 1654),里面放 `agent.json`、身份目录(`fleet-enroll` 注册之后的 `agent-id/`),Probe 放 `probe.json` 和 `agents.json`。`agent.json` 里的 `fleet.identity_dir` 写容器里的路径(例如 `/etc/monitor/agent-id`)。 +- **首次注册 Agent**:`podman run --rm --network host -v <数据目录>:/etc/monitor --entrypoint /usr/local/bin/fleet-enroll <镜像> --server https://<面板> --join <加入令牌> --name <名字> --identity-dir /etc/monitor/agent-id`,再写 `agent.json`。Probe:在管理端创建(或轮换密钥)之后下载 `probe.json`,放进数据目录。 +- **拉取私有镜像**需要 Registry 的登录信息:用 `podman login --authfile /etc/monitor-update/registry-auth.json …` 生成一份只有 root 能读(600)的认证文件,更新器用它拉取,容器本身用不到。 +- **挂载宿主机的 `/etc/os-release`(只读)**:节点读系统名展示在面板上,不挂的话读到的是镜像里的 Ubuntu。 +- **ICMP 目标任务**要在 `podman run` 里加 `--cap-add NET_RAW`。 +- **经隧道注册之后检查 Agent 的 `public_host`**:面板看到的来源地址是隧道的出口(通常是内网地址),要在管理端改成这台机器自己的公网地址(`PATCH /api/admin/agents/{id}`),Probe 才连得上。 +- `packaging/compose/` 里的 compose 文件是不带宿主机更新器的简单方式:更新靠手动改版本再 `docker compose pull && up -d`,面板的更新按钮对它不起作用。 + ## 容器节点的更新(Agent、Probe) 面板通知、节点验签、宿主机更新,**面板即使被攻破也装不上假镜像**: @@ -215,6 +247,7 @@ CI 构建并推镜像(Zot)──► ci/release-manifest.sh:对镜像 diges - **镜像仓库、服务名、公钥都在本机配置里**,清单里写什么都不会改变拉取哪个仓库;清单被篡改、签名对不上、组件或仓库不符、`seq` 不比已安装的大(重放旧清单),都会被拒绝,状态是 `invalid`。 - **业务进程不能改二进制,也不执行命令**:它只能往 `update/` 里放清单和请求;状态在 root 拥有的 `update-status/` 里(只读)。 - **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,作用域 `release`,只能登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。 +- **管理端**:「系统 → 版本」页显示最新发布的 tag、每个节点的更新状态(已是最新、有新版本、更新中、已回滚、清单无效),有新版本的节点有「更新」按钮,也可以「全部更新」。更新策略在节点本机配置:`/etc/monitor-update/<组件>.conf` 里 `POLICY=manual`(默认,等管理员请求)或 `auto`(验签通过就更新)。 - **发布公钥**在 `packaging/update/release-key.pub.pem`;私钥不入库,请离线备份。更换密钥:节点上换新公钥之后再用新私钥发布。 - 纯二进制的安装方式(`fleet-updater`、`fleet-release`,见 [fleet/update.md](fleet/update.md))还没有实现;容器方式优先。 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/docs/roadmap.md b/docs/roadmap.md index 2ade908..5575f63 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,6 +62,8 @@ ## F2 Fleet 更新(在 M6 之前) +> **状态**:容器节点(Agent、Probe)的更新已经实现并在线上验证过(签名发布清单、面板转发、宿主机更新器验签、按 digest 拉取、健康确认和回滚,见 [deployment.md](deployment.md) 的「容器节点的更新」)。**还没实现**:纯二进制安装方式的 `fleet-updater` 和 `fleet-release`、Windows 服务和 OpenWrt 的适配器、通道与灰度(现在由管理员在管理端手动选节点)、`POLICY=auto` 的线上验证。下面保留原来的完整设计。 + - `Fleet.Update`、`fleet-updater`(systemd 与 Windows 服务适配器、健康信标、回滚、防回滚)、`fleet-release`(CI 签名)、`Fleet.Server.Releases`(通道、灰度、版本汇总)。 **验收**: @@ -81,7 +83,11 @@ ## 实现状态(后端与 Agent) -已实现并有自动化测试(287 个,另有 2 个跳过的测试,其中 1 个需要运行中 NativeAOT 产物): +已实现并有自动化测试(331 个,另有 2 个跳过的测试,其中 1 个需要运行中 NativeAOT 产物): + +- **Probe 直连面板**:Probe 用自己的身份连 Backend(`probe.json` 里有 `server`),Agent 列表由面板作为期望状态 `probe_agents` 下发;Agent 不再中转列表和 Probe 的系统信息。 +- **节点信息与构建信息**:系统、架构、国家和运营商(Backend 用来源地址查询,节点不访问外部服务)、版本、tag、提交和构建时间;展示端按 `site.show_build` 开关显示,管理端「系统 → 版本」看全部。 +- **容器节点的更新**:CI 对镜像 digest 签名,面板转发,宿主机 `monitor-update` 验签、拉取、重启并在不健康时回滚;管理端有更新状态和按钮,CI 用只能登记清单的 `release` 令牌。 - **多 Probe**:Probe 身份由 Backend 生成(创建、轮换密钥、吊销、删除),一条线路是一对(Probe,Agent),Agent 为每个 Probe 会话各起一组流,数据按 `line:` 隔离;Agent 上报连着的 Probe(id、版本、远端地址、时间),管理端可以断开某个 Probe;吊销立即生效。 - **`tcp_bulk` 原语**:批量传输(fwd / rev、本地码率上限、两端的 tcp_info),评分用持续 1 秒的吞吐峰值。 diff --git a/packaging/compose/agent/compose.yaml b/packaging/compose/agent/compose.yaml index 48cad7c..3b641c5 100644 --- a/packaging/compose/agent/compose.yaml +++ b/packaging/compose/agent/compose.yaml @@ -9,7 +9,8 @@ # {"fleet":{"server":"https://","identity_dir":"/etc/monitor/agent-id"},"listen":{"bind":"0.0.0.0"},"limits":{"max_total_mbps":100,"max_total_pps":10000}} # docker compose up -d # -# 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。.data 不动,身份和历史都保留。 +# 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。 +# 这种方式没有宿主机更新器:管理端的「更新」按钮对它不起作用;要自动更新请用 podman + systemd(docs/deployment.md「容器节点的运行」)。.data 不动,身份和历史都保留。 services: agent: image: ${MONITOR_IMAGE:-registry.oeo.one/monitor/monitor-agent}:${MONITOR_VERSION:-0.1.1} @@ -20,6 +21,7 @@ services: network_mode: host volumes: - ./.data:/etc/monitor + - /etc/os-release:/etc/os-release:ro # 让节点读到宿主机的系统名(镜像里是 Ubuntu) read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 diff --git a/packaging/compose/probe/compose.yaml b/packaging/compose/probe/compose.yaml index 7c1a2a8..ea18454 100644 --- a/packaging/compose/probe/compose.yaml +++ b/packaging/compose/probe/compose.yaml @@ -5,7 +5,8 @@ # 在管理端创建 Probe,下载一次性身份配置,保存为 .data/probe.json(含私钥,权限 600,不要提交到版本控制) # docker compose up -d # -# 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。.data 不动。 +# 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。 +# 这种方式没有宿主机更新器:管理端的「更新」按钮对它不起作用;要自动更新请用 podman + systemd(docs/deployment.md「容器节点的运行」)。.data 不动。 services: probe: image: ${MONITOR_IMAGE:-registry.oeo.one/monitor/monitor-probe}:${MONITOR_VERSION:-0.1.1} @@ -15,6 +16,7 @@ services: network_mode: host volumes: - ./.data:/etc/monitor + - /etc/os-release:/etc/os-release:ro # 让节点读到宿主机的系统名(镜像里是 Ubuntu) read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 diff --git a/web/README.md b/web/README.md index 298fcfa..815f7c8 100644 --- a/web/README.md +++ b/web/README.md @@ -5,7 +5,7 @@ | 目录 | 内容 | 访问 | | --- | --- | --- | | `display/` | 展示端:Agent 卡片总览(线路质量 + 主机资源占用,实时刷新)、线路详情(延迟、丢包、抖动、TCP、UDP、评分、历史)、线路对比 | `/`,匿名可看;站点没有公开时提示登录 | -| `admin/` | 管理端:Agent(添加、审批、启停、接管、删除、加入令牌)、Probe(创建、轮换密钥、吊销、删除、连接)、任务、按需测试、系统(设置、API 令牌、用户、事件、原始数据、分析) | `/admin/`,要登录 | +| `admin/` | 管理端:Agent(添加、审批、启停、接管、删除、加入令牌)、Probe(创建、轮换密钥、吊销、删除、连接)、任务、按需测试、系统(设置、版本与更新、API 令牌、用户、事件、原始数据、分析) | `/admin/`,要登录 | | `shared/` | `theme/`(「纸墨」主题令牌和基础样式)、`api/`(客户端、从 OpenAPI 生成的类型)、`charts/`(uPlot 封装)、`ui/`(通用组件) | | ```sh 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..53c58b2 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,41 @@ 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; +} + +/* 悬浮提示弹在卡片右边,会盖到下一张卡片上:每张卡片都有自己的层叠上下文(毛玻璃),所以悬停(提示打开)的那张要整张抬到兄弟卡片之上 */ +.agent-card:hover, +.agent-card:has(.pop.open) { + z-index: 30; +} 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': '按需测试的原始数据块保留多少天',