feat: choose probe/agent links from the admin, fix the overview popover [CI]

The panel decides which probes link to which agents (probes dial out): an
auto-link setting plus per-pair manual overrides with a link matrix in the
admin, and the hovered overview card is lifted above its neighbours.
This commit is contained in:
justme committed 2026-10-06 23:22:45 +08:00
1 parent d449e50791
commit 6e945657c1
32 files changed
+788 -39

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -47,7 +47,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
monitor-agent --config /etc/monitor/agent.json
```
也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-agent:<version>`,自动选择 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:<version>`,自动选择 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。
+203
View File
@@ -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"
},
+2
View File
@@ -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:<version>`,容器默认读取 `/etc/monitor/probe.json`,数据目录要允许 UID/GID 1654 读写。
## 构建
需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建:
@@ -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;
/// <summary>线路开关:哪些(Probe,Agent)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>;这里看每一对的结果,并且手动强制开、关或取消手动设置。</summary>
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<LinkMatrixDto>().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<LinkDto>().ProducesProblems(400, 401, 403, 404);
}
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
{
LinkSnapshot snapshot = await links.SnapshotAsync(ct);
IReadOnlyList<AgentView> agentViews = await agents.ListAsync(ct);
var items = new List<LinkDto>();
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<IResult> 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));
}
@@ -63,9 +63,14 @@ public static class SystemEndpoints
private static async Task<IResult> ListSettingsAsync(SettingsService settings, CancellationToken ct) =>
TypedResults.Json(new PageDto<SettingDto>([.. (await settings.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto);
private static async Task<IResult> PatchSettingsAsync(HttpContext http, SettingsService settings, Dictionary<string, JsonElement> changes)
private static async Task<IResult> PatchSettingsAsync(HttpContext http, SettingsService settings, AgentService agents, Dictionary<string, JsonElement> changes)
{
Result<IReadOnlyList<SettingView>> 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<SettingView>? views, out FleetError? error)
? TypedResults.Json(new PageDto<SettingDto>([.. views.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto)
: Problems.From(http, error);
@@ -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");
@@ -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<ProbeConnectionDto> Connections);
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
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<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps);
/// <summary><c>probe.json</c>:含私钥,只在生成时返回一次。</summary>
@@ -37,6 +37,9 @@ namespace Monitor.Backend.Api;
[JsonSerializable(typeof(AgentDto))]
[JsonSerializable(typeof(PageDto<AgentDto>))]
[JsonSerializable(typeof(AgentPatchRequest))]
[JsonSerializable(typeof(LinkDto))]
[JsonSerializable(typeof(LinkMatrixDto))]
[JsonSerializable(typeof(SetLinkRequest))]
[JsonSerializable(typeof(BuildDto))]
[JsonSerializable(typeof(ReleaseDto))]
[JsonSerializable(typeof(PageDto<ReleaseDto>))]
@@ -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."),
@@ -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);
}
/// <summary>Probe 需要连接的 Agent 列表(所有已激活的 Agent)。版本号在列表变化时递增。</summary>
public async ValueTask<ProbeAgentList> ProbeAgentsAsync(CancellationToken ct)
/// <summary>这个 Probe 要连接的 Agent 列表(已激活、有公开地址,并且线路是开着的)。版本号在列表变化时递增。</summary>
public async ValueTask<ProbeAgentList> ProbeAgentsAsync(string probeId, CancellationToken ct)
{
LinkSnapshot links = await _links.SnapshotAsync(ct);
IReadOnlyList<AgentRecord> 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
{
@@ -0,0 +1,68 @@
namespace Monitor.Backend.Application;
/// <summary>某一时刻的「线路」开关:自动连接的总开关,加上每一对(Probe,Agent)的手动设置。</summary>
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;
}
/// <summary>自动连接:开着时,没有手动设置的 Probe 和 Agent 都会连;关着时,只有手动开启的才连。</summary>
public bool Auto { get; }
/// <summary>手动设置:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 没设置(跟随自动连接)。</summary>
public bool? OverrideOf(string probeId, string agentId) => _overrides.TryGetValue((probeId, agentId), out bool value) ? value : null;
/// <summary>这一对最终连不连:手动设置优先,没有就看自动连接。</summary>
public bool IsLinked(string probeId, string agentId) => OverrideOf(probeId, agentId) ?? Auto;
}
/// <summary>
/// 哪些(Probe,Agent)要连成线路。由面板决定、Probe 主动拨出:自动连接开着时所有 Probe 和 Agent 两两连(默认),
/// 关着时只连手动选中的;每一对还可以手动强制开或关。手动设置存在设置表里(<c>link.&lt;probe&gt;.&lt;agent&gt;</c>,值 1 或 0,空表示没设置)。
/// 判断结果分别用在 Probe 的 Agent 列表、Agent 允许的 Probe(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
/// </summary>
public sealed class LinkService
{
private const string Prefix = "link.";
private readonly ISettingsStore _settings;
public LinkService(ISettingsStore settings)
{
_settings = settings;
}
public async ValueTask<LinkSnapshot> SnapshotAsync(CancellationToken ct)
{
IReadOnlyDictionary<string, string> 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);
}
/// <summary>手动设置一对线路:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 取消手动设置。</summary>
public ValueTask SetAsync(string probeId, string agentId, bool? enabled, CancellationToken ct) =>
_settings.SetAsync($"{Prefix}{probeId}.{agentId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
/// <summary>Probe 或 Agent 被删除:清掉所有涉及它的手动设置。</summary>
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);
}
}
}
@@ -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();
}
@@ -29,6 +29,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<HostStatusService>();
services.AddSingleton<NodeInfoService>();
services.AddSingleton<ProbeService>();
services.AddSingleton<LinkService>();
services.AddSingleton<LineService>();
services.AddSingleton<TestService>();
services.AddSingleton<IngestService>();
@@ -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;
}
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对。</summary>
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
public async ValueTask<IReadOnlyList<LineView>> ListAsync(Func<AgentView, bool> include, CancellationToken ct)
{
IReadOnlyList<AgentView> agents = await _agents.ListAsync(ct);
IReadOnlyList<ProbeRecord> 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)))];
}
/// <summary>
@@ -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<TaskRecord> all = await _tasks.ListAsync(ct);
IReadOnlyList<ProbeRecord> 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) });
}
@@ -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));
}
@@ -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<HttpResponseMessage> 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<JsonNode?> LinkAsync(string probe, string agent) =>
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["probe_id"]!.GetValue<string>() == probe && i["agent_id"]!.GetValue<string>() == agent);
[Fact]
public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue<string>();
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<bool>());
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<bool>());
// 取消手动设置:回到自动连接
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<bool>());
// 自动连接关着,手动选中的照样连
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<bool>());
}
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<string>();
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);
}
}
+2
View File
@@ -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` | 首次启动时通过命令行或环境变量创建初始管理员 |
+33
View File
@@ -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:<digest>,更新器更新时改写它
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))还没有实现;容器方式优先。
+1
View File
@@ -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`,面板无法修改)
+7 -1
View File
@@ -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:<probe_id>` 隔离;Agent 上报连着的 Probe(id、版本、远端地址、时间),管理端可以断开某个 Probe;吊销立即生效。
- **`tcp_bulk` 原语**:批量传输(fwd / rev、本地码率上限、两端的 tcp_info),评分用持续 1 秒的吞吐峰值。
+3 -1
View File
@@ -9,7 +9,8 @@
# {"fleet":{"server":"https://<backend>","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
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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
+79 -1
View File
@@ -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<LinkMatrixDto>('/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 (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<b>线路</b>
<span className="muted">Probe 主动连接 Agent。点格子手动开关;「↺」回到自动。</span>
<label className="switch right">
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
</label>
</div>
<ErrorNotice error={matrix.error} />
<table className="table">
<thead>
<tr>
<th>Probe \ Agent</th>
{agents.map(([id, name]) => (
<th key={id}>{name}</th>
))}
</tr>
</thead>
<tbody>
{probes.map(([probeId, probeName]) => (
<tr key={probeId}>
<td style={{ fontWeight: 600 }}>{probeName}</td>
{agents.map(([agentId]) => {
const link = cell(probeId, agentId)
if (!link) return <td key={agentId} />
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连'
return (
<td key={agentId}>
<span className="row" style={{ gap: 6 }}>
<button type="button" className="btn small" disabled={busy} title={link.linked ? '点一下:手动关闭' : '点一下:手动开启'} onClick={() => set(link, !link.linked)}>
<Chip tone={tone}>{text}</Chip>
</button>
{link.override !== null && link.override !== undefined ? (
<>
<span className="faint" title="手动设置">
手动
</span>
<button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => set(link, null)}>
↺
</button>
</>
) : null}
</span>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
/** 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() {
<ErrorNotice error={probes.error} />
<div className="stack">
<LinkMatrix />
{probes.data && list.length === 0 ? (
<div className="card">
<Empty>还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。</Empty>
+39 -9
View File
@@ -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;
}
+6 -11
View File
@@ -95,18 +95,13 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
return (
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
<div className="line-top">
<span className="line-name">
→ {infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
{l.probe_name}
<span className="line-name" title={[l.probe_name, infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version].filter(Boolean).join(' · ')}>
<span className="arrow">→</span>
{infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
<span className="name">{l.probe_name}</span>
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
</span>
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version ? (
<span className="probe-tag" title="Probe 版本">
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version}
</span>
) : null}
<span className="faint">近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
{!l.probe_connected && l.online ? <Chip tone="warn">未连接</Chip> : <span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>}
</div>
<LineStrips lineId={l.id} range={range} m={m} />
</Link>
+1
View File
@@ -83,5 +83,6 @@ export const api = {
get: <T>(path: string, query?: Query, init?: RequestInit) => request<T>('GET', withQuery(path, query), undefined, init),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
del: <T>(path: string) => request<T>('DELETE', path),
}
+151
View File
@@ -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;
+2
View File
@@ -19,6 +19,8 @@ export type BuildInfoDto = Narrow<S['BuildInfoDto']>
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
export type NodeUpdateDto = Narrow<S['NodeUpdateDto']>
export type ReleaseDto = Narrow<S['ReleaseDto']>
export type LinkDto = Narrow<S['LinkDto']>
export type LinkMatrixDto = Narrow<S['LinkMatrixDto']>
export type OverviewDto = Narrow<S['OverviewDto']>
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
export type SeriesDto = Narrow<S['SeriesDto']>
+1
View File
@@ -143,6 +143,7 @@ export function translateCode(code: string): string | undefined {
const SETTINGS: Record<string, string> = {
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
'fleet.auto_link': '所有 Probe 自动连接所有 Agent(关闭后只连在「Probe → 线路」里手动选中的)',
'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent、Probe 的版本、tag 和构建时间',
'retention.raw_days': '持续任务的原始数据块保留多少天',
'retention.test_raw_days': '按需测试的原始数据块保留多少天',