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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user