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:
1 parent
d449e50791
commit
6e945657c1
32 files changed
+788
-39
No files matched your search
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.<probe>.<agent></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);
|
||||
}
|
||||
}
|
||||
@@ -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` | 首次启动时通过命令行或环境变量创建初始管理员 |
|
||||
|
||||
|
||||
@@ -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))还没有实现;容器方式优先。
|
||||
|
||||
@@ -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
@@ -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 秒的吞吐峰值。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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']>
|
||||
|
||||
@@ -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': '按需测试的原始数据块保留多少天',
|
||||
|
||||
Reference in new issue
Block a user