From 88b1a0a9bbcedd7e8427b8bc3171d9d7472b8f92 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 21:56:19 +0800 Subject: [PATCH] feat: probe host stats, node cards with a separate lines panel grouped by carrier, Windows host sampling, manual carrier override [CI] --- README.md | 2 +- .../agent/src/MoShiro.Agent/AgentConfig.cs | 11 +- backend/openapi/v1.json | 30 ++- backend/probe/src/MoShiro.Probe/ProbeApp.cs | 41 +++- .../probe/src/MoShiro.Probe/ProbeConfig.cs | 5 + .../MoShiro.Measurement.csproj | 1 + .../Platform}/HostSampler.cs | 34 ++- .../Platform/ReportingSection.cs | 14 ++ .../Platform/WindowsHost.cs | 125 ++++++++++ .../Rpc/MoShiroMethodTable.cs | 4 +- .../Admin/AgentEndpoints.cs | 27 ++- .../Contracts/AdminDtos.cs | 3 +- .../Contracts/PublicDtos.cs | 11 +- .../Public/HostEndpoints.cs | 50 +++- .../Public/LiveEndpoints.cs | 36 ++- .../Public/NodeEndpoints.cs | 2 +- .../Agents/HostStatusService.cs | 4 +- .../Live/LiveHub.cs | 2 +- .../Nodes/IspClassifier.cs | 11 + .../Nodes/NodeInfo.cs | 3 +- .../Nodes/NodeInfoService.cs | 34 ++- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 11 +- .../SqliteNodeMetaStore.cs | 12 +- .../tests/MoShiro.Backend.Tests/HostTests.cs | 83 ++++++- docs/api/api.md | 6 +- docs/design/architecture.md | 4 +- docs/design/protocol.md | 2 +- docs/ops/deployment.md | 2 +- docs/security/enrollment.md | 4 +- packaging/compose/probe/compose.yaml | 9 + web/admin/src/pages/Probes.tsx | 26 ++ web/display/src/display.css | 66 +++++ web/display/src/live.ts | 13 +- web/display/src/pages/Line.tsx | 2 +- web/display/src/pages/Overview.tsx | 229 +++++++++++------- web/shared/api/schema.ts | 16 +- 36 files changed, 750 insertions(+), 185 deletions(-) rename backend/{agent/src/MoShiro.Agent/Host => shared/MoShiro.Measurement/Platform}/HostSampler.cs (82%) create mode 100644 backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs create mode 100644 backend/shared/MoShiro.Measurement/Platform/WindowsHost.cs diff --git a/README.md b/README.md index beea4c2..2855487 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P **它不做什么** - **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。 -- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。 +- **不是主机监控**:Agent 和 Probe 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字(Probe 可以在本地配置里关掉),不采集型号、主机名、进程和任何 IP。 - **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。 - 暂不包含告警(见文末待办)。 diff --git a/backend/agent/src/MoShiro.Agent/AgentConfig.cs b/backend/agent/src/MoShiro.Agent/AgentConfig.cs index e27cabe..8f20c8c 100644 --- a/backend/agent/src/MoShiro.Agent/AgentConfig.cs +++ b/backend/agent/src/MoShiro.Agent/AgentConfig.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using MoShiro.Measurement; namespace MoShiro.Agent; @@ -28,16 +29,6 @@ public sealed class AgentConfig public string? RestartAt { get; set; } } -public sealed class ReportingSection -{ - /// 上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。 - [JsonPropertyName("host_stats")] - public bool HostStats { get; set; } = true; - - [JsonPropertyName("host_interval_s")] - public int HostIntervalSeconds { get; set; } = 2; -} - public sealed class FleetSection { [JsonPropertyName("server")] diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 671e1fe..b47374f 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -815,7 +815,7 @@ "tags": [ "public" ], - "summary": "Latest host resource usage of every visible agent (no hardware model, no addresses)", + "summary": "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", "operationId": "hosts_list", "responses": { "200": { @@ -841,16 +841,16 @@ } } }, - "/api/public/agents/{agent_id}/host": { + "/api/public/hosts/{node_id}": { "get": { "tags": [ "public" ], - "summary": "Host resource usage history of one agent (columnar, averaged per step)", + "summary": "Host resource usage history of one agent or probe (columnar, averaged per step)", "operationId": "hosts_history", "parameters": [ { - "name": "agent_id", + "name": "node_id", "in": "path", "required": true, "schema": { @@ -6584,7 +6584,8 @@ }, "HostItemDto": { "required": [ - "agent_id", + "id", + "kind", "name", "online", "stale", @@ -6593,7 +6594,10 @@ ], "type": "object", "properties": { - "agent_id": { + "id": { + "type": "string" + }, + "kind": { "type": "string" }, "name": { @@ -6622,7 +6626,7 @@ }, "HostSeriesDto": { "required": [ - "agent_id", + "id", "from", "to", "step", @@ -6631,7 +6635,7 @@ ], "type": "object", "properties": { - "agent_id": { + "id": { "type": "string" }, "from": { @@ -7379,6 +7383,10 @@ "null", "string" ] + }, + "isp_manual": { + "type": "boolean", + "default": false } } }, @@ -8206,6 +8214,12 @@ "null", "string" ] + }, + "isp": { + "type": [ + "null", + "string" + ] } } }, diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index c5b1804..b8b9984 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -22,11 +22,15 @@ public sealed class ProbeApp : IDisposable private readonly SemaphoreSlim _changed = new(0); private readonly INodeLog _log; private readonly FleetLink _link; + private readonly ReportingSection _reporting; + private readonly TimeProvider _time; /// 注册时由节点自己生成:Identity 用来连面板,PeerIdentity 是另一把独立的密钥,只用来连 Agent。 public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) { _log = log; + _time = time; + _reporting = file.Reporting; var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log); var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log); @@ -105,6 +109,7 @@ public sealed class ProbeApp : IDisposable } Task link = _link.RunAsync(ct); + Task host = ReportHostAsync(ct); try { while (!ct.IsCancellationRequested) @@ -120,7 +125,41 @@ public sealed class ProbeApp : IDisposable finally { await ReconcileAsync([], CancellationToken.None); - await Task.WhenAny(link, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None)); + await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None)); + } + } + + /// 每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。 + private async Task ReportHostAsync(CancellationToken ct) + { + if (!_reporting.HostStats) + { + return; + } + + var sampler = new HostSampler(_time); + sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线 + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_reporting.HostIntervalSeconds, 1, 60)), _time); + try + { + while (await timer.WaitForNextTickAsync(ct)) + { + if (_link.CurrentSession is { } session) + { + try + { + await session.NotifyAsync(MoShiroMethods.Host, sampler.Sample(), MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _log.Warn($"could not report host status: {ex.Message}"); + } + } + } + } + catch (OperationCanceledException) + { + // 停止 } } diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs index 262c33b..233d8fc 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using MoShiro.Measurement; using MoShiro.Protocol; namespace MoShiro.Probe; @@ -18,6 +19,10 @@ public sealed class ProbeFile [JsonPropertyName("limits")] public ProbeLimits Limits { get; set; } = new(); + + /// 上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 host_stats: false 就一个字也不上报。 + [JsonPropertyName("reporting")] + public ReportingSection Reporting { get; set; } = new(); } public sealed class ProbeFleetSection diff --git a/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj b/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj index 6f2c5f1..388332a 100644 --- a/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj +++ b/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj @@ -1,6 +1,7 @@ true + true diff --git a/backend/agent/src/MoShiro.Agent/Host/HostSampler.cs b/backend/shared/MoShiro.Measurement/Platform/HostSampler.cs similarity index 82% rename from backend/agent/src/MoShiro.Agent/Host/HostSampler.cs rename to backend/shared/MoShiro.Measurement/Platform/HostSampler.cs index 05e1791..7c30c04 100644 --- a/backend/agent/src/MoShiro.Agent/Host/HostSampler.cs +++ b/backend/shared/MoShiro.Measurement/Platform/HostSampler.cs @@ -1,13 +1,13 @@ using System.Globalization; using MoShiro.Protocol; -namespace MoShiro.Agent; +namespace MoShiro.Measurement; /// -/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 +/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// 只有汇总的数字:不采集 CPU 型号、主机名、网卡名和任何 IP 地址,所以面板和展示页想泄漏也泄漏不出去。 /// Linux 读 /proc 和 /sys;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。 -/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。 +/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。 /// public sealed class HostSampler { @@ -18,6 +18,7 @@ public sealed class HostSampler private readonly Func<(long Total, long Used)?> _disk; private readonly string _procRoot; private readonly string _sysRoot; + private readonly bool _native; private (long Total, long Idle)? _previousCpu; private (long At, long Rx, long Tx)? _previousNet; @@ -25,6 +26,7 @@ public sealed class HostSampler string? procRoot = null, string? sysRoot = null, string? diskRoot = null) { _time = time; + _native = read is null && OperatingSystem.IsWindows(); // 只有默认读法才走 Windows 的系统调用;测试注入 read 时仍按 Linux 的文本解析 _read = read ?? ReadProc; _procRoot = Root(procRoot, "HOST_PROC", "/proc"); _sysRoot = Root(sysRoot, "HOST_SYS", "/sys"); @@ -37,7 +39,8 @@ public sealed class HostSampler long now = _time.GetTimestamp(); var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; - if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu) + (long Total, long Idle)? cpuNow = _native ? WindowsCpu() : _read(Path.Combine(_procRoot, "stat")) is { } stat ? ParseCpu(stat) : null; + if (cpuNow is { } cpu) { if (_previousCpu is { } before && cpu.Total > before.Total) { @@ -48,7 +51,8 @@ public sealed class HostSampler _previousCpu = cpu; } - if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory) + (long Total, long Used)? memoryNow = _native ? WindowsMemory() : _read(Path.Combine(_procRoot, "meminfo")) is { } meminfo ? ParseMemory(meminfo) : null; + if (memoryNow is { } memory) { snapshot.MemTotal = memory.Total; snapshot.MemUsed = memory.Used; @@ -60,11 +64,21 @@ public sealed class HostSampler snapshot.DiskUsed = disk.Used; } - if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net) + (long Rx, long Tx, long? LinkMbps)? netNow = null; + if (_native) + { + netNow = WindowsNetwork(); + } + else if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } parsed) + { + netNow = (parsed.Rx, parsed.Tx, ReadLinkSpeed(dev)); + } + + if (netNow is { } net) { snapshot.NetRxTotal = net.Rx; snapshot.NetTxTotal = net.Tx; - snapshot.NetLinkMbps = ReadLinkSpeed(dev); + snapshot.NetLinkMbps = net.LinkMbps; if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx) { double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds); @@ -83,6 +97,12 @@ public sealed class HostSampler return snapshot; } + private static (long Total, long Idle)? WindowsCpu() => OperatingSystem.IsWindows() ? WindowsHost.Cpu() : null; + + private static (long Total, long Used)? WindowsMemory() => OperatingSystem.IsWindows() ? WindowsHost.Memory() : null; + + private static (long Rx, long Tx, long? LinkMbps)? WindowsNetwork() => OperatingSystem.IsWindows() ? WindowsHost.Network() : null; + /// 第一行 cpu user nice system idle iowait irq softirq steal …:总时间与空闲时间(idle + iowait)。 public static (long Total, long Idle)? ParseCpu(string stat) { diff --git a/backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs b/backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs new file mode 100644 index 0000000..9ae461c --- /dev/null +++ b/backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace MoShiro.Measurement; + +/// 节点本地配置里的 reporting 段(面板无法修改):Agent 和 Probe 共用。 +public sealed class ReportingSection +{ + /// 上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。 + [JsonPropertyName("host_stats")] + public bool HostStats { get; set; } = true; + + [JsonPropertyName("host_interval_s")] + public int HostIntervalSeconds { get; set; } = 2; +} diff --git a/backend/shared/MoShiro.Measurement/Platform/WindowsHost.cs b/backend/shared/MoShiro.Measurement/Platform/WindowsHost.cs new file mode 100644 index 0000000..3819b6f --- /dev/null +++ b/backend/shared/MoShiro.Measurement/Platform/WindowsHost.cs @@ -0,0 +1,125 @@ +using System.Net.NetworkInformation; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace MoShiro.Measurement; + +/// +/// Windows 上读主机资源占用( 在 Linux 之外的实现):CPU 时间和物理内存用两个只读的系统调用 +/// (GetSystemTimes、GlobalMemoryStatusEx),网卡流量和链路速率用 .NET 自带的 。 +/// 和 Linux 一样只有汇总的数字:不读网卡名、MAC、IP,也不启动任何进程。 +/// +[SupportedOSPlatform("windows")] +internal static partial class WindowsHost +{ + private static readonly string[] s_virtualWords = ["Virtual", "vEthernet", "Hyper-V", "VMware", "VirtualBox", "TAP-", "Wintun", "WireGuard", "Npcap", "Bluetooth", "Loopback", "isatap", "Teredo"]; + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime + { + public uint Low; + public uint High; + + public readonly long Value => (long)(((ulong)High << 32) | Low); + } + + [StructLayout(LayoutKind.Sequential)] + private struct MemoryStatus + { + public uint Length; + public uint MemoryLoad; + public ulong TotalPhys; + public ulong AvailPhys; + public ulong TotalPageFile; + public ulong AvailPageFile; + public ulong TotalVirtual; + public ulong AvailVirtual; + public ulong AvailExtendedVirtual; + } + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GlobalMemoryStatusEx(ref MemoryStatus status); + + /// 系统启动以来的 CPU 总时间和空闲时间(和 Linux 的 /proc/stat 第一行同一个意思:两次采样相减得到占用)。 + public static (long Total, long Idle)? Cpu() + { + try + { + if (!GetSystemTimes(out FileTime idle, out FileTime kernel, out FileTime user)) + { + return null; + } + + // 内核时间里已经包含了空闲时间 + return (kernel.Value + user.Value, idle.Value); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return null; + } + } + + /// 物理内存的总量和已用(总量减去可用)。 + public static (long Total, long Used)? Memory() + { + try + { + var status = new MemoryStatus { Length = (uint)Marshal.SizeOf() }; + if (!GlobalMemoryStatusEx(ref status) || status.TotalPhys == 0) + { + return null; + } + + return ((long)status.TotalPhys, (long)(status.TotalPhys - Math.Min(status.AvailPhys, status.TotalPhys))); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return null; + } + } + + /// 所有物理网卡(在用的,排除回环、隧道、虚拟网卡)的累计收发字节,以及链路速率之和(Mbit/s;有一块读不到速率就是 null)。 + public static (long Rx, long Tx, long? LinkMbps)? Network() + { + try + { + long rx = 0; + long tx = 0; + long mbps = 0; + bool speedKnown = true; + bool any = false; + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel + || s_virtualWords.Any(w => nic.Description.Contains(w, StringComparison.OrdinalIgnoreCase) || nic.Name.Contains(w, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + IPInterfaceStatistics stats = nic.GetIPStatistics(); + rx += stats.BytesReceived; + tx += stats.BytesSent; + any = true; + if (nic.Speed > 0) + { + mbps += nic.Speed / 1_000_000; + } + else + { + speedKnown = false; + } + } + + return any ? (rx, tx, speedKnown && mbps > 0 ? mbps : null) : null; + } + catch (NetworkInformationException) + { + return null; + } + } +} diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs index ada3f4a..380b7c8 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs @@ -34,10 +34,10 @@ public static class MoShiroMethodTable MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server), - // 节点面:Agent → Backend + // 节点面:节点 → Backend MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent), MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent), - MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node, NodeRoles.Agent), + MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总 // 对等面:Probe(拨号方)→ Agent(监听方) MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer), diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 741b792..5a0a58f 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -179,15 +179,32 @@ public static class AgentEndpoints ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found"); - private static async Task PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct) + private static async Task PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct) { - if (request.Name is null) + if (request.Name is null && request.Isp is null) { - return Problems.From(http, ValidationError.Single("name", "is required")); + return Problems.From(http, ValidationError.Single("name", "name or isp is required")); } - Result result = await probes.RenameAsync(id, request.Name, ct); - return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error); + if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null) + { + return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto")); + } + + Result result = request.Name is null + ? (await probes.GetAsync(id, ct) is { } existing ? Result.Ok(existing) : Result.Fail(MoShiroErrors.ProbeNotFound, "probe not found")) + : await probes.RenameAsync(id, request.Name, ct); + if (!result.TryGet(out ProbeView? view, out FleetError? error)) + { + return Problems.From(http, error); + } + + if (request.Isp is not null) + { + await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct); + } + + return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto); } private static async Task RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index def5dba..d9711e8 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -125,7 +125,8 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList Items); public sealed record SetLinkRequest(bool? Enabled); -public sealed record PatchProbeRequest(string? Name); +/// Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。 +public sealed record PatchProbeRequest(string? Name, string? Isp = null); public sealed record TaskDto( string Id, diff --git a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs index 5033e40..318c514 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs @@ -177,9 +177,9 @@ public sealed record HostDto( long? UptimeSeconds, DateTimeOffset At); -/// 一个 Agent 的最新主机状态;stale 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。 +/// 一个节点(Agent 或 Probe)的最新主机状态;stale 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。 /// 节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。 -public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null); +public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false); /// 一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 null。 public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt); @@ -187,9 +187,10 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin /// 公开的「关于」:站点没有打开「显示构建信息」时只有 show_build: false。 public sealed record AboutDto(bool ShowBuild, BuildDto? Backend); -public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host); +/// agent 或 probe。 +public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host); -public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host); +public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host); /// 主机资源占用的历史(列式,按步长取平均)。 -public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList T, IReadOnlyDictionary Values); +public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList T, IReadOnlyDictionary Values); diff --git a/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs index 1dc2031..6147243 100644 --- a/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs @@ -7,22 +7,27 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Api; -/// Agent 的主机资源占用:最新值(实时的部分走 SSE 的 hosts 主题)和历史曲线。只有汇总的数字,没有型号和 IP。 +/// +/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 hosts 主题)和历史曲线。只有汇总的数字,没有型号和 IP。 +/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。 +/// public static class HostEndpoints { private const string Tag = "public"; + private const string AgentKind = "agent"; + private const string ProbeKind = "probe"; public static void Map(RouteGroupBuilder group) { group.MapGet("/hosts", ListAsync) - .Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag) + .Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag) .Returns>().ProducesProblems(403); - group.MapGet("/agents/{agent_id}/host", HistoryAsync) - .Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag) + group.MapGet("/hosts/{node_id}", HistoryAsync) + .Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag) .Returns().ProducesProblems(403, 404, 422); } - private static async Task ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct) + private static async Task ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct) { var items = new List(); foreach (AgentView view in await agents.ListAsync(ct)) @@ -33,18 +38,39 @@ public static class HostEndpoints } (HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id); - items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host))); + items.Add(new HostItemDto(view.Agent.Id.Value, AgentKind, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host))); + } + + foreach (ProbeView view in await probes.ListAsync(ct)) + { + if (view.Revoked) + { + continue; + } + + (HostSnapshot? host, bool stale) = hosts.Latest(new NodeId(view.Probe.Id)); + items.Add(new HostItemDto(view.Probe.Id, ProbeKind, view.Probe.Name, view.Online, stale, host is null ? null : Mappers.Time(host.AtUtcMs), host is null ? null : Mappers.ToDto(host))); } return TypedResults.Json(new PageDto(items, null), ApiJsonContext.Default.PageDtoHostItemDto); } - private static async Task HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct) + private static async Task HistoryAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct) { - AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct); - if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))) + bool visible; + if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal)) { - return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found"); + AgentView? view = await agents.GetAsync(new NodeId(node_id), ct); + visible = view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)); + } + else + { + visible = node_id.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) && await probes.GetAsync(node_id, ct) is { Revoked: false }; + } + + if (!visible) + { + return Problems.Create(http, MoShiroErrors.AgentNotFound, "node not found"); } if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day) @@ -54,7 +80,7 @@ public static class HostEndpoints long now = time.GetUtcNow().ToUnixTimeMilliseconds(); long from = now - span; - IReadOnlyList samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct); + IReadOnlyList samples = await hosts.HistoryAsync(new NodeId(node_id), from, now + 60_000, ct); // 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均 long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000); @@ -92,6 +118,6 @@ public static class HostEndpoints ["rx_bps"] = Average(s => s.NetRxBps), ["tx_bps"] = Average(s => s.NetTxBps), }; - return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto); + return TypedResults.Json(new HostSeriesDto(node_id, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto); } } diff --git a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs index 476656a..055de02 100644 --- a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs @@ -32,7 +32,7 @@ public static class LiveEndpoints } private static async Task StreamAsync( - HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter, + HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter, BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version) { string ipVersion = ip_version ?? IpVersions.V4; @@ -84,7 +84,7 @@ public static class LiveEndpoints http.Response.Headers["X-Accel-Buffering"] = "no"; await http.Response.Body.FlushAsync(http.RequestAborted); - var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion); + var session = new LiveSession(http, agents, probes, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion); using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping); await session.RunAsync(linked.Token); } @@ -105,6 +105,8 @@ public static class LiveEndpoints { private readonly HttpContext _http; private readonly AgentService _agents; + private readonly ProbeService _probes; + private readonly HostStatusService _hosts; private readonly LineService _lines; private readonly IReadOnlyDictionary _lineKeys; private readonly string _ipVersion; @@ -124,10 +126,12 @@ public static class LiveEndpoints private long _lastWriteMs; private long _lastPeriodicMs; - public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList topics, IReadOnlyDictionary lineKeys, string ipVersion) + public LiveSession(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList topics, IReadOnlyDictionary lineKeys, string ipVersion) { _http = http; _agents = agents; + _probes = probes; + _hosts = hosts; _lines = lines; _lineKeys = lineKeys; _ipVersion = ipVersion; @@ -171,7 +175,7 @@ public static class LiveEndpoints _nodeSignals.Enqueue(node); break; case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts): - _hostSignals[host.AgentId] = host; + _hostSignals[host.Node] = host; break; case ChunkSignal chunk: _dirtyLines.Add((chunk.AgentId, chunk.Series)); @@ -216,10 +220,20 @@ public static class LiveEndpoints foreach (HostSignal host in hostSignals) { - AgentView? view = await _agents.GetAsync(host.AgentId, ct); + if (host.Node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal)) + { + if (await _probes.GetAsync(host.Node.Value, ct) is { Revoked: false } probe) + { + await WriteAsync("host", new LiveHostDto(host.Node.Value, "probe", probe.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct); + } + + continue; + } + + AgentView? view = await _agents.GetAsync(host.Node, ct); if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active) { - await WriteAsync("host", new LiveHostDto(host.AgentId.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct); + await WriteAsync("host", new LiveHostDto(host.Node.Value, "agent", view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct); } } @@ -284,7 +298,15 @@ public static class LiveEndpoints case LiveTopicKind.Hosts: foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null)) { - await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct); + await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, "agent", view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct); + } + + foreach (ProbeView view in await _probes.ListAsync(ct)) + { + if (!view.Revoked && _hosts.Latest(new NodeId(view.Probe.Id)).Host is { } host) + { + await WriteAsync("host", new LiveHostDto(view.Probe.Id, "probe", view.Online, Mappers.ToDto(host)), ApiJsonContext.Default.LiveHostDto, ct); + } } break; diff --git a/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs index e71ba87..f5db601 100644 --- a/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs @@ -61,7 +61,7 @@ public static class NodeEndpoints var items = (await nodes.ListAsync(ct)) .Where(n => visible.Contains(n.NodeId)) .OrderBy(n => n.NodeId, StringComparer.Ordinal) - .Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null)) + .Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual)) .ToList(); http.Response.Headers.CacheControl = "no-store"; return TypedResults.Json(new PageDto(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto); diff --git a/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs b/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs index 2c7c0df..0103de4 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs @@ -4,8 +4,8 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// -/// moshiro.host:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。 -/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。 +/// moshiro.host:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。 +/// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。 /// public sealed class HostStatusService { diff --git a/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs b/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs index 963cb3c..2c62242 100644 --- a/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs +++ b/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs @@ -17,7 +17,7 @@ public sealed record ChunkSignal(NodeId AgentId, string Series) : LiveSignal; public sealed record TestSignal(TestId TestId) : LiveSignal; /// 某个 Agent 的主机资源占用有了新的快照(实时推给展示页)。 -public sealed record HostSignal(NodeId AgentId, HostSnapshot Host) : LiveSignal; +public sealed record HostSignal(NodeId Node, HostSnapshot Host) : LiveSignal; public sealed class LiveSubscription : IDisposable { diff --git a/backend/src/MoShiro.Backend.Application/Nodes/IspClassifier.cs b/backend/src/MoShiro.Backend.Application/Nodes/IspClassifier.cs index c0c45c4..6f79532 100644 --- a/backend/src/MoShiro.Backend.Application/Nodes/IspClassifier.cs +++ b/backend/src/MoShiro.Backend.Application/Nodes/IspClassifier.cs @@ -66,6 +66,17 @@ public static class IspClassifier return (Other, Shorten(org)); } + /// 手动指定运营商时的中文名;不认识的取值返回 null。 + public static string? NameOf(string key) => key switch + { + Telecom => "中国电信", + Unicom => "中国联通", + Mobile => "中国移动", + Broadnet => "中国广电", + Other => "其他", + _ => null, + }; + private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase); /// 组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。 diff --git a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs index af23b65..1e57477 100644 --- a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs +++ b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs @@ -16,7 +16,8 @@ public sealed record NodeInfo( string? Version = null, string? Tag = null, string? Commit = null, - string? BuiltAt = null); + string? BuiltAt = null, + bool IspManual = false); /// /// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。 diff --git a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs index 09930ab..45d2926 100644 --- a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs +++ b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs @@ -42,6 +42,31 @@ public sealed class NodeInfoService return [.. _cache.Values.Select(m => m.Info)]; } + /// + /// 手动指定节点的运营商(telecom / unicom / mobile / broadnet / other);传 null 回到自动识别(下次节点出现时重新查)。 + /// 返回 false 表示取值不认识。 + /// + public async ValueTask SetIspAsync(string nodeId, string kind, string? key, CancellationToken ct) + { + await EnsureLoadedAsync(ct); + NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind); + if (key is null) + { + // 清掉手动值,并让下一次心跳重新查询 + await SaveAsync(current with { Info = current.Info with { IspKey = null, IspName = null, IspManual = false }, GeoHost = null, GeoAtMs = 0, UpdatedAtMs = Now() }, ct); + return true; + } + + string? name = IspClassifier.NameOf(key); + if (name is null) + { + return false; + } + + await SaveAsync(current with { Info = current.Info with { IspKey = key, IspName = name, IspManual = true }, UpdatedAtMs = Now() }, ct); + return true; + } + /// 节点自报的系统、架构和构建信息;没有变化就不写库。 public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct) { @@ -101,8 +126,15 @@ public sealed class NodeInfoService return; } - (string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization); string country = geo.Country.ToUpperInvariant(); + if (current.Info.IspManual) + { + // 运营商是手动指定的:只更新国家,不覆盖运营商 + await SaveAsync(current with { Info = current.Info with { Country = country }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct); + return; + } + + (string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization); await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct); } finally diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 3434073..4ab7a1e 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 15; + public const int CurrentVersion = 16; public static void Migrate(SqliteConnection connection) { @@ -250,6 +250,15 @@ internal static class Schema connection.Exec("PRAGMA user_version = 15"); transaction.Commit(); } + + if (version < 16) + { + // 运营商可以手动指定(自动识别会错,例如 CN2 线路):手动的不会被后面的 IP 归属查询覆盖 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE node_meta ADD COLUMN isp_manual INTEGER NOT NULL DEFAULT 0"); + connection.Exec("PRAGMA user_version = 16"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs index c2d8c63..1850c6e 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs @@ -14,20 +14,20 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at FROM node_meta", Read), + c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual FROM node_meta", Read), ct); public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at) " + - "VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt) ON CONFLICT(node_id) DO UPDATE SET " + + "INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual) " + + "VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt, @im) ON CONFLICT(node_id) DO UPDATE SET " + "kind = excluded.kind, os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " + "isp_key = excluded.isp_key, isp_name = excluded.isp_name, geo_host = excluded.geo_host, geo_at = excluded.geo_at, updated_at = excluded.updated_at, " + - "version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at", + "version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at, isp_manual = excluded.isp_manual", ("@id", meta.Info.NodeId), ("@kind", meta.Info.Kind), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country), ("@ik", meta.Info.IspKey), ("@in", meta.Info.IspName), ("@gh", meta.GeoHost), ("@ga", meta.GeoAtMs), ("@ua", meta.UpdatedAtMs), - ("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt)), + ("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt), ("@im", meta.Info.IspManual ? 1 : 0)), ct); public ValueTask DeleteAsync(string nodeId, CancellationToken ct) => @@ -36,6 +36,6 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore private static NodeMeta Read(SqliteDataReader r) { static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i); - return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14)), S(r, 8), r.GetInt64(9), r.GetInt64(10)); + return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14), r.GetInt64(15) != 0), S(r, 8), r.GetInt64(9), r.GetInt64(10)); } } diff --git a/backend/tests/MoShiro.Backend.Tests/HostTests.cs b/backend/tests/MoShiro.Backend.Tests/HostTests.cs index 75ccee3..c82b6c7 100644 --- a/backend/tests/MoShiro.Backend.Tests/HostTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/HostTests.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using MoShiro.Agent; +using MoShiro.Measurement; using MoShiro.Protocol; using MoShiro.TestSupport; @@ -24,6 +25,23 @@ public sealed class HostSamplerTests Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1 + "podman0: 8888 1 0 0 0 0 0 0 9999 1 0 0 0 0 0 0\n")); } + [Fact] + public async Task Sample_OnWindows_ReadsCpuMemoryAndNetworkFromTheSystem() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var sampler = new HostSampler(TimeProvider.System); + sampler.Sample(); + await Task.Delay(300, TestContext.Current.CancellationToken); + HostSnapshot snapshot = sampler.Sample(); + Assert.NotNull(snapshot.CpuPercent); + Assert.InRange(snapshot.CpuPercent!.Value, 0, 100); + Assert.True(snapshot.MemTotal > 0 && snapshot.MemUsed >= 0 && snapshot.MemUsed <= snapshot.MemTotal); + } + [Fact] public void Sample_DerivesCpuPercentAndNetworkSpeedFromTheDifferenceBetweenTwoReads() { @@ -123,13 +141,15 @@ public sealed class HostStatusTests await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts"); await agent.SendHostAsync(Snapshot(18.5)); - SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["agent_id"]!.GetValue() == id); + SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["id"]!.GetValue() == id); + Assert.Equal("agent", live.Data!["kind"]!.GetValue()); Assert.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue()); Assert.True(live.Data["online"]!.GetValue()); // REST:最新值,没有过期 JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); - JsonNode item = list["items"]!.AsArray().Single(i => i!["agent_id"]!.GetValue() == id)!; + JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue() == id)!; + Assert.Equal("agent", item["kind"]!.GetValue()); Assert.False(item["stale"]!.GetValue()); Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue()); Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue()); @@ -150,7 +170,7 @@ public sealed class HostStatusTests JsonNode history = null!; await BackendFixture.EventuallyAsync(async () => { - history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/agents/{id}/host?range=1h"); + history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/hosts/{id}?range=1h"); return history["values"]!["cpu"]!.AsArray().Any(v => v is not null); }); Assert.Equal(25.0, history["values"]!["mem"]!.AsArray().Last(v => v is not null)!.GetValue()); @@ -159,8 +179,61 @@ public sealed class HostStatusTests // 隐藏的 Agent 对匿名访客不可见 await _backend.Admin.PatchAsync($"/api/admin/agents/{id}", BackendFixture.Json(new JsonObject { ["public_visible"] = false })); JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); - Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["agent_id"]!.GetValue() == id); - using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/agents/{id}/host"); + Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["id"]!.GetValue() == id); + using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/hosts/{id}"); Assert.Equal(System.Net.HttpStatusCode.NotFound, denied.StatusCode); } + + [Fact] + public async Task ARealProbeReportsItsHostStatus_LikeAnAgent_NeverCarriesModelsOrAddresses_AndDisappearsWhenRevoked() + { + await _backend.SiteMustBePublicAsync(); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => file.Reporting.HostIntervalSeconds = 1); + + JsonNode? item = null; + await BackendFixture.EventuallyAsync( + async () => + { + JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); + item = list["items"]!.AsArray().SingleOrDefault(i => i!["id"]!.GetValue() == probe.ProbeId); + return item?["host"] is not null; + }, + TimeSpan.FromSeconds(30), + "the probe's host status shows up"); + Assert.Equal("probe", item!["kind"]!.GetValue()); + Assert.NotNull(item["host"]!["uptime_seconds"]); // 任何平台都读得到运行时长 + foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" }) + { + Assert.DoesNotContain(forbidden, item.ToJsonString(), StringComparison.OrdinalIgnoreCase); + } + + // 历史也按节点 id 取 + using HttpResponseMessage history = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}?range=1h"); + Assert.Equal(System.Net.HttpStatusCode.OK, history.StatusCode); + + // 吊销之后展示页就看不到它了 + using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{probe.ProbeId}/revoke", null); + Assert.True(revoked.IsSuccessStatusCode); + JsonNode after = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); + Assert.DoesNotContain(after["items"]!.AsArray(), i => i!["id"]!.GetValue() == probe.ProbeId); + using HttpResponseMessage gone = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, gone.StatusCode); + } + + [Fact] + public async Task AProbeThatTurnsReportingOff_SendsNothing() + { + await _backend.SiteMustBePublicAsync(); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => + { + file.Reporting.HostStats = false; + file.Reporting.HostIntervalSeconds = 1; + }); + + await Task.Delay(TimeSpan.FromSeconds(4)); + + JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); + JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue() == probe.ProbeId)!; + Assert.Null(item["host"]); // 节点还在,但一个字也没上报 + } } diff --git a/docs/api/api.md b/docs/api/api.md index 4a28d00..deb4cf0 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -91,14 +91,14 @@ | 端点 | 内容 | | --- | --- | -| `GET /api/public/hosts` | 每个对外可见的 Agent 的最新快照:`{agent_id, name, online, stale, last_seen_at, host}`;超过 15 s 没有更新 `stale` 为真(离线,或者 Agent 关了上报) | -| `GET /api/public/agents/{agent_id}/host?range=1h` | 历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` | +| `GET /api/public/hosts` | 每个对外可见的 Agent 和没被吊销的 Probe 的最新快照:`{id, kind, name, online, stale, last_seen_at, host}`(`kind` 是 `agent` 或 `probe`);超过 15 s 没有更新 `stale` 为真(离线,或者节点关了上报) | +| `GET /api/public/hosts/{node_id}?range=1h` | Agent 或 Probe 的历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` | `host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。 ### 2.3.2 节点的展示信息 -`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 Probe 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。**没有 IP 地址、城市、ASN、经纬度**,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。 +`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 Probe 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name, isp_manual}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。**没有 IP 地址、城市、ASN、经纬度**,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。 `GET /api/public/about`:`{show_build, backend}`。`show_build` 关着时 `backend` 是 `null`;打开后 `backend` 是 `{version, tag, built_at}`(后端自己的版本、tag、构建时间,不含提交号)。 diff --git a/docs/design/architecture.md b/docs/design/architecture.md index abe704e..7cb9a1f 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -6,7 +6,7 @@ - 在**若干 Probe**(家宽等需要观测的出口,可以有多个)与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**;哪些 Probe 连哪些 Agent 由面板决定(自动全连,也可以手动选择); - 由 Backend 的分析层推算:**如果在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务,体验会怎样**,并对多条线路排序和对比; -- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 不上报主机资源。节点只在连接面板时自报粗粒度的系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新;国家和运营商由 Backend 用节点的来源地址查询,节点自己不访问任何外部服务。展示页看不到任何 IP。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、自更新等能力:更新由节点宿主机上的更新器完成,节点进程只往数据目录放面板转来的发布清单和更新请求。 +- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 和 Probe 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 的上报可以在 `probe.json` 里关掉(`reporting.host_stats: false`)。展示页用卡片画出每个 Agent 和 Probe 的主机状态,线路单独放在卡片下面的面板里。节点只在连接面板时自报粗粒度的系统、架构和构建信息(版本、tag、提交、构建时间),用于展示和更新;国家和运营商由 Backend 用节点的来源地址查询,节点自己不访问任何外部服务。展示页看不到任何 IP。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、自更新等能力:更新由节点宿主机上的更新器完成,节点进程只往数据目录放面板转来的发布清单和更新请求。 ## 2. 拓扑 @@ -113,7 +113,7 @@ Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问 - **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。 - **身份**:节点使用本地生成的 ECDSA P-256 密钥对签名认证(Agent 和 Probe 都自己生成;Probe 有两把独立的密钥,一把连面板、一把连 Agent),Backend 只存公钥,数据库泄露也无法冒充节点。 - **防放大**:未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。 -- **容器里的主机指标**:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名;读不到的项返回空值,不会退回去读容器自己的数据。Probe 只需要挂载 `/etc/os-release`。 +- **容器里的主机指标**:容器里的 Agent 通过只读挂载宿主机的 `/proc`、`/sys`、根目录和 `/etc/os-release`(环境变量 `HOST_PROC`、`HOST_SYS`、`HOST_ROOT`)读取宿主机的资源占用和系统名;读不到的项返回空值,不会退回去读容器自己的数据。Probe 的 compose 也一样挂载这几项。 - **自身状态**:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`)。 ## 6. 可扩展性目标 diff --git a/docs/design/protocol.md b/docs/design/protocol.md index 684a267..71c1874 100644 --- a/docs/design/protocol.md +++ b/docs/design/protocol.md @@ -33,7 +33,7 @@ MoShiro 的两条控制链路都建立在 Fleet 之上: | `moshiro.test.cancel` | B→A | 请求 | `test_id` | | `moshiro.test.status` | A→B | 通知 | `test_id`、`status`(`running`、`phase_done`、`succeeded`、`failed`、`aborted`)、`phase?`、`error_code?` | | `moshiro.event` | A→B | 通知 | `kind`(`probe_connected`(`detail` 是 `probe_id`,`probe` 里带版本、远端地址和连接时间)、`probe_disconnected`、`endpoint_changed`、`session_rejected`、`clock_step`、`gap`、`cpu_saturated`(节点进程 CPU 饱和,可能影响测量,由 Probe 或 Agent 上报))、`at_utc`、`detail` | -| `moshiro.host` | A→B | 通知 | `HostSnapshot`:主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP),每 `host_interval_s` 秒一次;`host_stats` 为 false 时不发,见 [enrollment.md](../security/enrollment.md) | +| `moshiro.host` | A、P→B | 通知 | `HostSnapshot`:主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP),每 `host_interval_s` 秒一次;`host_stats` 为 false 时不发,见 [enrollment.md](../security/enrollment.md) | 心跳的附加字段里有 `probes[]`:Agent 当前连着的每个 Probe(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 Probe 的 `version`(只用于展示)。块头里的 `probe_id` 以 Agent 认证过的会话为准,Probe 自报的值会被覆盖。 diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index 9f06c22..0817918 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -302,7 +302,7 @@ Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd 磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 I/O。链路速率是参与流量统计的接口速率合计,单位 Mbit/s,不是互联网测速结果;任一接口不支持读取速率时显示空值。接口仍按名称排除回环、容器、网桥和部分隧道,复杂 bonding/VLAN 配置可能存在重复计数。主机名、硬件型号、MAC/IP 不作为资源指标上报。 -Probe 保持现有功能,只上报系统、架构与节点状态,不上报 CPU、内存、磁盘或网卡指标。因此 Probe 的 Compose 模板只需要配置目录和 `/etc/os-release` 挂载;从 Agent systemd 示例改为 Probe 时,去掉 `/proc`、`/sys`、`/` 挂载及 `HOST_*` 环境变量。 +Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 Probe 的 compose 和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。 ## 容器节点的更新(Agent、Probe) diff --git a/docs/security/enrollment.md b/docs/security/enrollment.md index ced222e..9ce8dba 100644 --- a/docs/security/enrollment.md +++ b/docs/security/enrollment.md @@ -100,10 +100,10 @@ Probe 启动 ## Agent 的本地配置:主机状态上报 -`agent.json` 的 `reporting` 段(面板无法修改): +`agent.json` 和 `probe.json` 的 `reporting` 段(面板无法修改): ```json { "reporting": { "host_stats": true, "host_interval_s": 2 } } ``` -`host_stats` 为 true(默认)时,Agent 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。 +`host_stats` 为 true(默认)时,Agent 和 Probe 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。 diff --git a/packaging/compose/probe/compose.yaml b/packaging/compose/probe/compose.yaml index 5993902..5aaf9d1 100644 --- a/packaging/compose/probe/compose.yaml +++ b/packaging/compose/probe/compose.yaml @@ -19,10 +19,19 @@ services: # .env 里的变量原样传进容器(不用带默认值的变量替换:有的 compose 提供者,比如 podman-compose,不替换环境变量里的默认值) env_file: - .env + environment: + HOST_PROC: /host/proc + HOST_SYS: /host/sys + HOST_ROOT: /host/root volumes: - probe-config:/etc/moshiro - probe-status:/etc/moshiro/update-status:ro - /etc/os-release:/etc/os-release:ro + # 主机资源占用(CPU、内存、磁盘、网卡流量、运行时长,只有汇总数字,没有型号和 IP):只读挂载宿主机的 /proc、/sys 和根目录; + # 不想上报:在 probe.json 里写 reporting.host_stats=false,再删掉下面三行挂载。Docker Desktop 上看到的是它的虚拟机,不是 Windows 或 macOS 本身。 + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index c64bcc6..00ae758 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -191,6 +191,15 @@ function NameDialog({ title, initial, onClose, onSubmit }: { title: string; init ) } +const ISP_CHOICES = [ + { value: 'auto', label: '运营商:自动识别' }, + { value: 'telecom', label: '中国电信' }, + { value: 'unicom', label: '中国联通' }, + { value: 'mobile', label: '中国移动' }, + { value: 'broadnet', label: '中国广电' }, + { value: 'other', label: '其他' }, +] + type Pending = { kind: 'revoke' | 'delete'; probe: ProbeDto } export function Probes() { @@ -200,6 +209,8 @@ export function Probes() { const [creating, setCreating] = useState(false) const [renaming, setRenaming] = useState() const [pending, setPending] = useState() + // 刚改过的运营商(公开的节点信息每分钟才刷新一次,先按这里显示) + const [isp, setIsp] = useState>({}) const list = probes.data?.items ?? [] @@ -246,6 +257,21 @@ export function Probes() { )} {p.id} +