feat: node info, build info and acrylic web theme
Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
This commit is contained in:
1 parent
10634bdd80
commit
14b0de3544
95 files changed
+5473
-477
No files matched your search
@@ -12,7 +12,7 @@ namespace Monitor.Agent;
|
||||
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
|
||||
public sealed class AgentApp : IDisposable
|
||||
{
|
||||
public const string Version = "0.1.1";
|
||||
public const string Version = Monitor.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly AgentConfig _config;
|
||||
private readonly LoadedNodeIdentity _identity;
|
||||
@@ -244,6 +244,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
var meta = new AgentHelloMeta
|
||||
{
|
||||
Platform = Monitor.Measurement.PlatformDetector.Detect(),
|
||||
TlsSpki = _identity.File.TlsSpki,
|
||||
ProbeTcpPort = ControlPort,
|
||||
ProbeUdpPorts = [.. UdpPorts],
|
||||
|
||||
@@ -64,6 +64,9 @@ public sealed class ProbeSession : DataSession
|
||||
|
||||
public long ConnectedAtUtcMs { get; init; }
|
||||
|
||||
/// <summary>Probe 自报的系统和架构(随列表同步一起来,只用于展示);还没报过就是 null。</summary>
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||
|
||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||
|
||||
@@ -220,7 +220,7 @@ public sealed class PeerHub
|
||||
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
|
||||
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
|
||||
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs };
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, Platform = p.Platform };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
|
||||
@@ -306,7 +306,15 @@ public sealed class PeerHub
|
||||
MonitorMethods.ProbeAgents,
|
||||
MonitorJsonContext.Default.ProbeAgentsParams,
|
||||
MonitorJsonContext.Default.ProbeAgentsResult,
|
||||
(_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct))
|
||||
(context, request, ct) =>
|
||||
{
|
||||
if (request.Platform is { } platform)
|
||||
{
|
||||
context.Session.Platform = platform;
|
||||
}
|
||||
|
||||
return uplink.RequestProbeAgentsAsync(request, ct);
|
||||
})
|
||||
.On(
|
||||
MonitorMethods.Clock,
|
||||
MonitorJsonContext.Default.ClockParams,
|
||||
|
||||
+381
-6
@@ -862,6 +862,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/nodes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public-nodes"
|
||||
],
|
||||
"summary": "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)",
|
||||
"operationId": "nodes_list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PageDtoOfNodeInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/about": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public-nodes"
|
||||
],
|
||||
"summary": "The backend's version, tag and build time, if the site shows build info",
|
||||
"operationId": "about_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AboutDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/catalog/kinds": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1770,7 +1832,7 @@
|
||||
"tags": [
|
||||
"admin-join-tokens"
|
||||
],
|
||||
"summary": "Revoke a join token",
|
||||
"summary": "Revoke a join token; with purge=true delete the record for good",
|
||||
"operationId": "join_tokens_revoke",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1780,6 +1842,13 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "purge",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3622,6 +3691,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/build-info": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Version, tag, commit and build time of the backend and every agent and probe",
|
||||
"operationId": "build_info_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BuildInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3746,6 +3856,28 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AboutDto": {
|
||||
"required": [
|
||||
"show_build",
|
||||
"backend"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"show_build": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"backend": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/BuildDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgentDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -4032,6 +4164,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildDto": {
|
||||
"required": [
|
||||
"version",
|
||||
"tag",
|
||||
"commit",
|
||||
"built_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"commit": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"built_at": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildInfoDto": {
|
||||
"required": [
|
||||
"backend",
|
||||
"nodes"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backend": {
|
||||
"$ref": "#/components/schemas/BuildDto"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeBuildDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChangePasswordRequest": {
|
||||
"required": [
|
||||
"password"
|
||||
@@ -4345,7 +4527,7 @@
|
||||
"required": [
|
||||
"token",
|
||||
"plaintext",
|
||||
"install_command"
|
||||
"install_commands"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4355,8 +4537,8 @@
|
||||
"plaintext": {
|
||||
"type": "string"
|
||||
},
|
||||
"install_command": {
|
||||
"type": "string"
|
||||
"install_commands": {
|
||||
"$ref": "#/components/schemas/InstallCommandsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4908,6 +5090,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"InstallCommandsDto": {
|
||||
"required": [
|
||||
"linux",
|
||||
"macos",
|
||||
"windows"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linux": {
|
||||
"type": "string"
|
||||
},
|
||||
"macos": {
|
||||
"type": "string"
|
||||
},
|
||||
"windows": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"JoinTokenDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -5120,7 +5321,8 @@
|
||||
"LoginRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
"password",
|
||||
"return_session"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5129,6 +5331,12 @@
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"return_session": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5137,7 +5345,8 @@
|
||||
"user_id",
|
||||
"username",
|
||||
"csrf_token",
|
||||
"expires_at"
|
||||
"expires_at",
|
||||
"session_token"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5152,6 +5361,12 @@
|
||||
},
|
||||
"expires_at": {
|
||||
"format": "date-time"
|
||||
},
|
||||
"session_token": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5292,6 +5507,139 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeBuildDto": {
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"os",
|
||||
"os_name",
|
||||
"arch",
|
||||
"version",
|
||||
"tag",
|
||||
"commit",
|
||||
"built_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"os_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"arch": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"commit": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"built_at": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeInfoDto": {
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"os",
|
||||
"os_name",
|
||||
"arch",
|
||||
"country",
|
||||
"isp_key",
|
||||
"isp_name"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"os_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"arch": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"country": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"isp_key": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"isp_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"OverviewDto": {
|
||||
"required": [
|
||||
"range",
|
||||
@@ -5564,6 +5912,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfNodeInfoDto": {
|
||||
"required": [
|
||||
"items",
|
||||
"next_cursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeInfoDto"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfPlanDto": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -6774,6 +7143,9 @@
|
||||
{
|
||||
"name": "public"
|
||||
},
|
||||
{
|
||||
"name": "public-nodes"
|
||||
},
|
||||
{
|
||||
"name": "catalog"
|
||||
},
|
||||
@@ -6807,6 +7179,9 @@
|
||||
{
|
||||
"name": "admin-users"
|
||||
},
|
||||
{
|
||||
"name": "admin-build"
|
||||
},
|
||||
{
|
||||
"name": "ops"
|
||||
},
|
||||
|
||||
@@ -163,10 +163,13 @@ public sealed class AgentConnection
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>本机的系统和架构:随列表同步一起告诉 Agent,只用于在面板上展示。</summary>
|
||||
private static readonly PlatformInfo s_platform = PlatformDetector.Detect();
|
||||
|
||||
private async Task RefreshDirectoryAsync(RpcSession<ConnectionSession> rpc, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeAgentsResult> list = await rpc.RequestAsync(
|
||||
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
|
||||
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version, Platform = s_platform }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
|
||||
if (list.TryGet(out ProbeAgentsResult? agents, out FleetError? error))
|
||||
{
|
||||
_env.Directory.Apply(agents);
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Monitor.Probe;
|
||||
/// </summary>
|
||||
public sealed class ProbeApp : IDisposable
|
||||
{
|
||||
public const string Version = "0.1.1";
|
||||
public const string Version = Monitor.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
private readonly Dictionary<string, Running> _connections = [];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 读出本机的系统名和架构,只用于在面板上展示。故意只取粗粒度的信息:
|
||||
/// 「Debian 12」「Windows 11」「x64」,不读主机名、内核版本、CPU 型号。
|
||||
/// </summary>
|
||||
public static class PlatformDetector
|
||||
{
|
||||
public static PlatformInfo Detect()
|
||||
{
|
||||
PlatformInfo info = Detect(File.Exists("/etc/os-release") ? File.ReadAllLines("/etc/os-release") : []);
|
||||
info.Version = Empty(BuildInfo.Version);
|
||||
info.Tag = Empty(BuildInfo.Tag);
|
||||
info.Commit = Empty(BuildInfo.Commit);
|
||||
info.BuiltAt = Empty(BuildInfo.BuiltAt);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static string? Empty(string value) => value.Length == 0 ? null : value;
|
||||
|
||||
/// <summary>`osRelease` 是 /etc/os-release 的各行(Linux 之外不用)。拆出来是为了好测。</summary>
|
||||
internal static PlatformInfo Detect(IEnumerable<string> osRelease)
|
||||
{
|
||||
string arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x64",
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.X86 => "x86",
|
||||
var other => other.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Windows 11 的版本号还是 10.0,靠内部版本号(22000 起)区分
|
||||
string name = Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10";
|
||||
return new PlatformInfo { Os = "windows", Name = name, Arch = arch };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new PlatformInfo { Os = "macos", Name = "macOS", Arch = arch };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return new PlatformInfo { Os = "linux", Name = LinuxName(osRelease), Arch = arch };
|
||||
}
|
||||
|
||||
return new PlatformInfo { Os = "other", Name = null, Arch = arch };
|
||||
}
|
||||
|
||||
/// <summary>从 os-release 里取发行版名字和主版本:「Debian GNU/Linux 12 (bookworm)」→「Debian 12」,「OpenWrt 24.10.0」→「OpenWrt 24.10」。</summary>
|
||||
internal static string LinuxName(IEnumerable<string> osRelease)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (string line in osRelease)
|
||||
{
|
||||
int eq = line.IndexOf('=', StringComparison.Ordinal);
|
||||
if (eq > 0)
|
||||
{
|
||||
values[line[..eq].Trim()] = line[(eq + 1)..].Trim().Trim('"', '\'');
|
||||
}
|
||||
}
|
||||
|
||||
string name = values.GetValueOrDefault("NAME") ?? values.GetValueOrDefault("ID") ?? "Linux";
|
||||
name = name.Replace(" GNU/Linux", string.Empty, StringComparison.Ordinal).Trim();
|
||||
string? version = values.GetValueOrDefault("VERSION_ID");
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
// 只留 主.次:Alpine 的 3.22.1、OpenWrt 的 24.10.0 都截成两段;Debian 的 12 本来就一段
|
||||
string[] parts = version.Split('.');
|
||||
return $"{name} {string.Join('.', parts.Take(2))}";
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,30 @@
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
</ItemGroup>
|
||||
<!--
|
||||
构建信息:版本来自 $(Version),tag、提交、构建时间来自环境变量 MONITOR_TAG / MONITOR_COMMIT / MONITOR_BUILT_AT
|
||||
(scripts/publish.sh 和 CI 会设置;本地开发不设置就是空,这样本地构建是可重复的、不会每次都重新编译)。
|
||||
生成一个带常量的源文件,NativeAOT 下不用反射。
|
||||
-->
|
||||
<Target Name="WriteBuildInfo" BeforeTargets="CoreCompile">
|
||||
<PropertyGroup>
|
||||
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="namespace Monitor.Protocol%3B" />
|
||||
<BuildInfoLine Include="/// <summary>这个程序是什么时候、从哪个版本构建出来的(构建时生成)。</summary>" />
|
||||
<BuildInfoLine Include="public static class BuildInfo" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public const string Version = "$(Version)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Tag = "$(MONITOR_TAG)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Commit = "$(MONITOR_COMMIT)"%3B" />
|
||||
<BuildInfoLine Include=" public const string BuiltAt = "$(MONITOR_BUILT_AT)"%3B" />
|
||||
<BuildInfoLine Include="}" />
|
||||
</ItemGroup>
|
||||
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
|
||||
<ItemGroup>
|
||||
<Compile Include="$(BuildInfoFile)" />
|
||||
<FileWrites Include="$(BuildInfoFile)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -152,6 +152,10 @@ public sealed class ProbeAgentsParams
|
||||
/// <summary>Probe 目前持有的列表版本(用于调试;服务端总是返回当前列表)。</summary>
|
||||
[JsonPropertyName("have_version")]
|
||||
public long HaveVersion { get; set; }
|
||||
|
||||
/// <summary>Probe 的系统和架构(只用于展示;Agent 记在会话上,随连接状态报给 Backend)。</summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProbeAgentsResult
|
||||
@@ -194,9 +198,41 @@ public sealed class LocalLimits
|
||||
public int MaxConcurrentTests { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点的系统和架构(粗粒度,只用于展示):<c>os</c> 是 linux / windows / macos / other,<c>name</c> 是「Debian 12」「Windows 11」这样的名字,
|
||||
/// <c>arch</c> 是 x64 / arm64 / arm / x86。<b>没有主机名、内核版本、具体型号</b>。另外带着这个程序自己的构建信息(版本、tag、构建时间)。
|
||||
/// </summary>
|
||||
public sealed class PlatformInfo
|
||||
{
|
||||
[JsonPropertyName("os")]
|
||||
public string Os { get; set; } = "other";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("arch")]
|
||||
public string? Arch { get; set; }
|
||||
|
||||
/// <summary>程序的版本、tag、提交(短)和构建时间(UTC,ISO 8601):<see cref="BuildInfo"/> 构建时写进去的,没设置就是空。</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; set; }
|
||||
|
||||
[JsonPropertyName("commit")]
|
||||
public string? Commit { get; set; }
|
||||
|
||||
[JsonPropertyName("built_at")]
|
||||
public string? BuiltAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
{
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string? TlsSpki { get; set; }
|
||||
|
||||
@@ -266,6 +302,9 @@ public sealed class ConnectedProbe
|
||||
|
||||
[JsonPropertyName("since_utc_ms")]
|
||||
public long SinceUtcMs { get; set; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
|
||||
@@ -434,6 +473,7 @@ public static class FragmentKeys
|
||||
[JsonSerializable(typeof(ProbeAgentsParams))]
|
||||
[JsonSerializable(typeof(ProbeAgentsResult))]
|
||||
[JsonSerializable(typeof(AgentEnrollMeta))]
|
||||
[JsonSerializable(typeof(PlatformInfo))]
|
||||
[JsonSerializable(typeof(AgentHelloMeta))]
|
||||
[JsonSerializable(typeof(AgentHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(RawChunkHeader))]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Monitor.Measurement.Tests;
|
||||
|
||||
public sealed class PlatformDetectorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"|NAME=\"Debian GNU/Linux\"|VERSION_ID=\"12\"|ID=debian", "Debian 12")]
|
||||
[InlineData("NAME=\"OpenWrt\"|VERSION_ID=\"24.10.0\"|ID=openwrt", "OpenWrt 24.10")]
|
||||
[InlineData("NAME=\"Alpine Linux\"|ID=alpine|VERSION_ID=3.22.1", "Alpine Linux 3.22")]
|
||||
[InlineData("NAME=\"Ubuntu\"|VERSION_ID=\"24.04\"", "Ubuntu 24.04")]
|
||||
[InlineData("ID=arch", "arch")]
|
||||
[InlineData("", "Linux")]
|
||||
public void LinuxName_IsTheDistroAndItsMajorMinorVersion(string osRelease, string expected)
|
||||
{
|
||||
Assert.Equal(expected, PlatformDetector.LinuxName(osRelease.Split('|', StringSplitOptions.RemoveEmptyEntries)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_ReportsOnlyCoarseInformation()
|
||||
{
|
||||
var platform = PlatformDetector.Detect();
|
||||
Assert.Contains(platform.Os, new[] { "linux", "windows", "macos", "other" });
|
||||
Assert.Contains(platform.Arch, new[] { "x64", "arm64", "arm", "x86" });
|
||||
// 没有主机名、内核版本:名字很短,也不含小数点以外的版本细节
|
||||
Assert.True((platform.Name ?? string.Empty).Length < 40);
|
||||
Assert.DoesNotContain(Environment.MachineName, platform.Name ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public static class AgentEndpoints
|
||||
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("join_tokens_revoke", "Revoke a join token", TokensTag)
|
||||
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/probes", ListProbesAsync)
|
||||
@@ -130,12 +130,20 @@ public static class AgentEndpoints
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, created.InstallCommand), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct)
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
|
||||
{
|
||||
if (purge == true)
|
||||
{
|
||||
await tokens.DeleteAsync(id, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await tokens.RevokeAsync(id, ct);
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ public static class AuthEndpoints
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
|
||||
bool returnSession = request.ReturnSession == true;
|
||||
if (!returnSession)
|
||||
{
|
||||
http.Response.Cookies.Append(
|
||||
PrincipalResolver.SessionCookie,
|
||||
login.SessionToken,
|
||||
@@ -55,13 +59,20 @@ public static class AuthEndpoints
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
||||
});
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs)), ApiJsonContext.Default.LoginResponse);
|
||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
|
||||
string? bearer = PrincipalResolver.BearerOf(http);
|
||||
if (bearer is not null)
|
||||
{
|
||||
await auth.LogoutAsync(bearer, http.RequestAborted); // 登录时 return_session 拿到的会话令牌;API 令牌不是会话,这里什么也不会发生
|
||||
}
|
||||
else if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
|
||||
{
|
||||
await auth.LogoutAsync(session, http.RequestAborted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>管理端的构建信息:后端自己和每个 Agent、Probe 的版本、tag、提交、构建时间(节点上报的,不含任何地址)。</summary>
|
||||
public static class BuildEndpoints
|
||||
{
|
||||
private const string Tag = "admin-build";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/build-info", GetAsync)
|
||||
.Api("build_info_get", "Version, tag, commit and build time of the backend and every agent and probe", Tag)
|
||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, CancellationToken ct)
|
||||
{
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items), ApiJsonContext.Default.BuildInfoDto);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ public static class ApiModule
|
||||
QueryEndpoints.Map(publicGroup);
|
||||
LiveEndpoints.Map(publicGroup);
|
||||
HostEndpoints.Map(publicGroup);
|
||||
NodeEndpoints.Map(publicGroup);
|
||||
CatalogEndpoints.Map(publicGroup);
|
||||
|
||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||
@@ -108,6 +109,7 @@ public static class ApiModule
|
||||
TaskEndpoints.Map(guarded);
|
||||
DataEndpoints.Map(guarded);
|
||||
SystemEndpoints.Map(guarded);
|
||||
BuildEndpoints.Map(guarded);
|
||||
|
||||
OpsEndpoints.Map(app);
|
||||
app.MapOpenApi("/openapi/v1.json");
|
||||
|
||||
@@ -23,14 +23,25 @@ public static class PrincipalResolver
|
||||
public const string SessionCookie = "monitor_session";
|
||||
public const string CsrfHeader = "X-CSRF-Token";
|
||||
|
||||
public static string? BearerOf(HttpContext http)
|
||||
{
|
||||
string? header = http.Request.Headers.Authorization;
|
||||
return header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : null;
|
||||
}
|
||||
|
||||
public static async Task ResolveAsync(HttpContext http, Func<Task> next)
|
||||
{
|
||||
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
|
||||
Principal? principal = null;
|
||||
string? header = http.Request.Headers.Authorization;
|
||||
if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
string? bearer = BearerOf(http);
|
||||
if (bearer is not null)
|
||||
{
|
||||
principal = await auth.AuthenticateTokenAsync(header["Bearer ".Length..].Trim(), http.RequestAborted);
|
||||
principal = await auth.AuthenticateTokenAsync(bearer, http.RequestAborted);
|
||||
if (principal is null && await auth.AuthenticateSessionAsync(bearer, http.RequestAborted) is { } viaSession)
|
||||
{
|
||||
// 登录时用 return_session 拿到的会话令牌:和 API 令牌一样走 Bearer,不是 Cookie,所以不需要 CSRF
|
||||
principal = viaSession with { Kind = PrincipalKind.Token, CsrfToken = null };
|
||||
}
|
||||
}
|
||||
else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session))
|
||||
{
|
||||
|
||||
@@ -2,9 +2,10 @@ using System.Text.Json;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
public sealed record LoginRequest(string Username, string Password);
|
||||
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
||||
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt);
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
|
||||
|
||||
@@ -46,7 +47,9 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, string InstallCommand);
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince);
|
||||
@@ -121,3 +124,8 @@ public sealed record CreateUserRequest(string Username, string Password);
|
||||
public sealed record ChangePasswordRequest(string Password);
|
||||
|
||||
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
||||
|
||||
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
|
||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt);
|
||||
|
||||
public sealed record BuildInfoDto(BuildDto Backend, IReadOnlyList<NodeBuildDto> Nodes);
|
||||
@@ -37,6 +37,12 @@ namespace Monitor.Backend.Api;
|
||||
[JsonSerializable(typeof(AgentDto))]
|
||||
[JsonSerializable(typeof(PageDto<AgentDto>))]
|
||||
[JsonSerializable(typeof(AgentPatchRequest))]
|
||||
[JsonSerializable(typeof(BuildDto))]
|
||||
[JsonSerializable(typeof(AboutDto))]
|
||||
[JsonSerializable(typeof(NodeBuildDto))]
|
||||
[JsonSerializable(typeof(BuildInfoDto))]
|
||||
[JsonSerializable(typeof(NodeInfoDto))]
|
||||
[JsonSerializable(typeof(PageDto<NodeInfoDto>))]
|
||||
[JsonSerializable(typeof(JoinTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateJoinTokenRequest))]
|
||||
|
||||
@@ -167,6 +167,15 @@ public sealed record HostDto(
|
||||
DateTimeOffset At);
|
||||
|
||||
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
|
||||
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
|
||||
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);
|
||||
|
||||
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
|
||||
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
|
||||
|
||||
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
|
||||
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
|
||||
|
||||
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
|
||||
|
||||
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的展示信息:系统、架构、国家、运营商。<b>只有这几项,没有任何 IP 地址、城市、ASN、经纬度</b>——
|
||||
/// 这些在 Backend 里只在查询的那一刻用到,存起来的记录里也没有,所以访客(和任何别的接口)都拿不到。
|
||||
/// </summary>
|
||||
public static class NodeEndpoints
|
||||
{
|
||||
private const string Tag = "public-nodes";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/nodes", ListAsync)
|
||||
.Api("nodes_list", "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)", Tag)
|
||||
.Returns<PageDto<NodeInfoDto>>().ProducesProblems(403);
|
||||
group.MapGet("/about", AboutAsync)
|
||||
.Api("about_get", "The backend's version, tag and build time, if the site shows build info", Tag)
|
||||
.Returns<AboutDto>().ProducesProblems(403);
|
||||
}
|
||||
|
||||
/// <summary>这个后端自己的构建信息;没有(本地构建)的字段是 <c>null</c>。</summary>
|
||||
internal static BuildDto Backend(bool withCommit) =>
|
||||
new(Monitor.Protocol.BuildInfo.Version, Empty(Monitor.Protocol.BuildInfo.Tag), withCommit ? Empty(Monitor.Protocol.BuildInfo.Commit) : null, Empty(Monitor.Protocol.BuildInfo.BuiltAt));
|
||||
|
||||
private static string? Empty(string value) => value.Length == 0 ? null : value;
|
||||
|
||||
private static async Task<IResult> AboutAsync(HttpContext http, SettingsService settings)
|
||||
{
|
||||
bool show = await settings.IsShowBuildAsync(http.RequestAborted);
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new AboutDto(show, show ? Backend(withCommit: false) : null), ApiJsonContext.Default.AboutDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
|
||||
{
|
||||
// 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe
|
||||
var visible = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (AgentView view in await agents.ListAsync(ct))
|
||||
{
|
||||
if (view.Agent.Status == Fleet.Server.NodeStatus.Active && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
||||
{
|
||||
visible.Add(view.Agent.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ProbeView probe in await probes.ListAsync(ct))
|
||||
{
|
||||
if (!probe.Revoked)
|
||||
{
|
||||
visible.Add(probe.Probe.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// 版本和 tag 只在站点打开「显示构建信息」时才给访客;关着就是 null
|
||||
bool showBuild = await settings.IsShowBuildAsync(ct);
|
||||
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))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand);
|
||||
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
|
||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||
|
||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||
public sealed class JoinTokenService
|
||||
@@ -55,11 +58,16 @@ public sealed class JoinTokenService
|
||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
|
||||
|
||||
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||
string command = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, command));
|
||||
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
|
||||
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
/// <summary>彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。</summary>
|
||||
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public sealed record SettingView(string Key, string Type, string Value, string D
|
||||
public sealed class SettingsService
|
||||
{
|
||||
public const string SitePublic = "site.public";
|
||||
public const string SiteShowBuild = "site.show_build";
|
||||
public const string RawDays = "retention.raw_days";
|
||||
public const string TestRawDays = "retention.test_raw_days";
|
||||
public const string Metric10sHours = "retention.metric_10s_hours";
|
||||
@@ -40,6 +41,7 @@ public sealed class SettingsService
|
||||
private static IReadOnlyList<SettingSpec> Known(BackendOptions o) =>
|
||||
[
|
||||
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(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."),
|
||||
@@ -114,6 +116,9 @@ public sealed class SettingsService
|
||||
return _sitePublic;
|
||||
}
|
||||
|
||||
/// <summary>访客能不能看到版本、tag 和构建时间(站点公开之后的另一道开关,默认关)。</summary>
|
||||
public async ValueTask<bool> IsShowBuildAsync(CancellationToken ct) => await _store.GetAsync(SiteShowBuild, ct) == "true";
|
||||
|
||||
public async ValueTask<int> GetIntAsync(string key, CancellationToken ct)
|
||||
{
|
||||
SettingSpec spec = Specs.First(s => s.Key == key);
|
||||
|
||||
@@ -9,12 +9,14 @@ public sealed class AgentEventService
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time)
|
||||
public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time, NodeInfoService nodes)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_time = time;
|
||||
_nodes = nodes;
|
||||
}
|
||||
|
||||
public async ValueTask OnEventAsync(NodeId agent, AgentEventParams e, CancellationToken ct)
|
||||
@@ -32,7 +34,8 @@ public sealed class AgentEventService
|
||||
switch (e.Kind)
|
||||
{
|
||||
case AgentEventKinds.ProbeConnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at, p.Platform) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
_nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote, e.Probe?.Platform);
|
||||
break;
|
||||
case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.RemoveProbe(e.Detail);
|
||||
|
||||
@@ -5,7 +5,7 @@ using Monitor.Protocol;
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs);
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs, PlatformInfo? Platform = null);
|
||||
|
||||
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
|
||||
public sealed class AgentRuntimeState
|
||||
|
||||
@@ -20,8 +20,11 @@ public sealed partial class AgentService
|
||||
private readonly TaskPlanner _planner;
|
||||
private readonly ISettingsStore _settings;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings)
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
@@ -114,6 +117,7 @@ public sealed partial class AgentService
|
||||
{
|
||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||
await _agents.DeleteAsync(id, ct);
|
||||
await _nodes.ForgetAsync(id.Value, ct);
|
||||
_runtime.Remove(id);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,11 @@ public sealed class MonitorNodeEventSink : INodeEventSink
|
||||
private readonly IEventStore _events;
|
||||
private readonly LiveHub _live;
|
||||
private readonly AgentService _service;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public MonitorNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service)
|
||||
public MonitorNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
@@ -87,7 +89,11 @@ public sealed class MonitorNodeEventSink : INodeEventSink
|
||||
state.LastHeartbeatAtMs = nodeEvent.AtMs;
|
||||
if (beat is not null)
|
||||
{
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs)));
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs, p.Platform)));
|
||||
foreach (ConnectedProbe p in beat.Probes)
|
||||
{
|
||||
_nodes.ObserveProbeInBackground(p.Id.Value, p.Remote, p.Platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -142,6 +148,7 @@ public sealed class MonitorNodeEventSink : INodeEventSink
|
||||
|
||||
// 端口或证书指纹变了,Probe 需要新的列表
|
||||
AgentRecord? after = await _agents.GetAsync(id, ct);
|
||||
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
|
||||
if (before is not null && after is not null
|
||||
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts))
|
||||
&& after.Status == NodeStatus.Active)
|
||||
|
||||
@@ -35,9 +35,11 @@ public sealed partial class ProbeService
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentService agentService, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time)
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentService agentService, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_probes = probes;
|
||||
_agents = agents;
|
||||
_agentService = agentService;
|
||||
@@ -138,6 +140,7 @@ public sealed partial class ProbeService
|
||||
bool deleted = await _probes.DeleteAsync(id, ct);
|
||||
if (deleted)
|
||||
{
|
||||
await _nodes.ForgetAsync(id, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<AgentService>();
|
||||
services.AddSingleton<AgentEventService>();
|
||||
services.AddSingleton<HostStatusService>();
|
||||
services.AddSingleton<NodeInfoService>();
|
||||
services.AddSingleton<ProbeService>();
|
||||
services.AddSingleton<LineService>();
|
||||
services.AddSingleton<TestService>();
|
||||
|
||||
@@ -55,6 +55,18 @@ public sealed class BackendOptions
|
||||
|
||||
public int MaxCompareAgents { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 测试方案里「固定时长」阶段(没有 <c>duration_s</c> 参数的,例如 connect、hold)的缩放比例,缺省 1。
|
||||
/// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。
|
||||
/// </summary>
|
||||
public double PhaseDurationScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 Probe、Agent 的来源地址查一次国家和运营商,
|
||||
/// 只留下国家代码和运营商名,IP 不对外。留空 = 不查。服务的使用条款(公益、不能商用、保留署名)由部署的人自己遵守。
|
||||
/// </summary>
|
||||
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 把「ASN 和组织名」归成几个常见的国内运营商:mobile / telecom / unicom / broadnet;别的是 other(显示组织名)。
|
||||
/// 只用来挑展示用的图标和中文名。
|
||||
/// </summary>
|
||||
public static class IspClassifier
|
||||
{
|
||||
public const string Mobile = "mobile";
|
||||
public const string Telecom = "telecom";
|
||||
public const string Unicom = "unicom";
|
||||
public const string Broadnet = "broadnet";
|
||||
public const string Other = "other";
|
||||
|
||||
private static readonly HashSet<int> s_mobile = [9808, 24400, 24445, 56040, 56041, 56042, 56044, 56046, 56047, 56048, 132525, 138407];
|
||||
private static readonly HashSet<int> s_telecom = [4134, 4809, 4811, 4812, 4813, 9394, 17638, 23724, 38283, 58466, 58519, 136188, 137693];
|
||||
private static readonly HashSet<int> s_unicom = [4808, 4837, 9929, 10099, 17621, 17622, 17623, 17816, 136958, 138421];
|
||||
private static readonly HashSet<int> s_broadnet = [63838, 140061];
|
||||
|
||||
public static (string Key, string Name) Classify(int? asn, string? organization)
|
||||
{
|
||||
if (asn is { } a)
|
||||
{
|
||||
if (s_mobile.Contains(a))
|
||||
{
|
||||
return (Mobile, "中国移动");
|
||||
}
|
||||
|
||||
if (s_telecom.Contains(a))
|
||||
{
|
||||
return (Telecom, "中国电信");
|
||||
}
|
||||
|
||||
if (s_unicom.Contains(a))
|
||||
{
|
||||
return (Unicom, "中国联通");
|
||||
}
|
||||
|
||||
if (s_broadnet.Contains(a))
|
||||
{
|
||||
return (Broadnet, "中国广电");
|
||||
}
|
||||
}
|
||||
|
||||
string org = organization ?? string.Empty;
|
||||
if (Has(org, "china mobile") || Has(org, "cmnet") || Has(org, "中国移动"))
|
||||
{
|
||||
return (Mobile, "中国移动");
|
||||
}
|
||||
|
||||
if (Has(org, "china telecom") || Has(org, "chinanet") || Has(org, "中国电信"))
|
||||
{
|
||||
return (Telecom, "中国电信");
|
||||
}
|
||||
|
||||
if (Has(org, "china unicom") || Has(org, "cncgroup") || Has(org, "中国联通"))
|
||||
{
|
||||
return (Unicom, "中国联通");
|
||||
}
|
||||
|
||||
if (Has(org, "china broadnet") || Has(org, "中国广电"))
|
||||
{
|
||||
return (Broadnet, "中国广电");
|
||||
}
|
||||
|
||||
return (Other, Shorten(org));
|
||||
}
|
||||
|
||||
private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
|
||||
internal static string Shorten(string organization)
|
||||
{
|
||||
string name = organization.Trim();
|
||||
foreach (string suffix in new[] { ", Inc.", " Inc.", ", LLC", " LLC", " Co.,Ltd.", " Co., Ltd.", " Co.,Ltd", " Co., Ltd", " Ltd.", " Ltd", " Corporation", " Corp.", " Limited", " GmbH", " B.V.", " S.A." })
|
||||
{
|
||||
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name[..^suffix.Length].TrimEnd(',', ' ');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return name.Length > 28 ? name[..28].TrimEnd() : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 一个节点(Agent 或 Probe)用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。
|
||||
/// <b>不含任何 IP 地址、城市、ASN 或经纬度</b>——这些只在查询的那一刻用到,不留下,也不对外。
|
||||
/// </summary>
|
||||
public sealed record NodeInfo(
|
||||
string NodeId,
|
||||
string Kind,
|
||||
string? Os,
|
||||
string? OsName,
|
||||
string? Arch,
|
||||
string? Country,
|
||||
string? IspKey,
|
||||
string? IspName,
|
||||
string? Version = null,
|
||||
string? Tag = null,
|
||||
string? Commit = null,
|
||||
string? BuiltAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
|
||||
/// 这个字段不会出现在任何对外的接口里。
|
||||
/// </summary>
|
||||
public sealed record NodeMeta(NodeInfo Info, string? GeoHost, long GeoAtMs, long UpdatedAtMs);
|
||||
|
||||
public static class NodeKinds
|
||||
{
|
||||
public const string Agent = "agent";
|
||||
public const string Probe = "probe";
|
||||
}
|
||||
|
||||
/// <summary>IP 归属查询的结果(只取需要的几项)。</summary>
|
||||
public sealed record GeoInfo(string? Country, int? Asn, string? Organization, bool IsPrivate);
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的展示信息:系统和架构(节点自报),国家和运营商(Backend 用节点的来源地址查一次)。
|
||||
/// <list type="bullet">
|
||||
/// <item>Probe、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。</item>
|
||||
/// <item>只查公网地址:回环、内网、CGNAT、链路本地的地址一律不发出去。</item>
|
||||
/// <item>查询结果只留国家代码和运营商;IP 不对外,也不和展示信息一起返回。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class NodeInfoService
|
||||
{
|
||||
/// <summary>同一个地址已经查过的结果保留多久,过期后在节点下次出现时重新查(运营商偶尔会变)。</summary>
|
||||
private static readonly TimeSpan s_geoTtl = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>查询失败之后多久再试。</summary>
|
||||
private static readonly TimeSpan s_retryAfter = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly INodeMetaStore _store;
|
||||
private readonly IGeoLookup _geo;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ConcurrentDictionary<string, NodeMeta> _cache = new();
|
||||
private readonly ConcurrentDictionary<string, byte> _inflight = new();
|
||||
private Task? _load;
|
||||
|
||||
public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_geo = geo;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>所有节点的展示信息(Agent 和 Probe)。</summary>
|
||||
public async ValueTask<IReadOnlyList<NodeInfo>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await EnsureLoadedAsync(ct);
|
||||
return [.. _cache.Values.Select(m => m.Info)];
|
||||
}
|
||||
|
||||
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
|
||||
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
|
||||
{
|
||||
if (platform is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureLoadedAsync(ct);
|
||||
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
|
||||
string? os = Clean(platform.Os, 16);
|
||||
string? name = Clean(platform.Name, 40);
|
||||
string? arch = Clean(platform.Arch, 16);
|
||||
string? version = Clean(platform.Version, 32);
|
||||
string? tag = Clean(platform.Tag, 64);
|
||||
string? commit = Clean(platform.Commit, 40);
|
||||
string? builtAt = Clean(platform.BuiltAt, 32);
|
||||
NodeInfo i = current.Info;
|
||||
if (i.Os == os && i.OsName == name && i.Arch == arch && i.Version == version && i.Tag == tag && i.Commit == commit && i.BuiltAt == builtAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveAsync(current with { Info = i with { Os = os, OsName = name, Arch = arch, Version = version, Tag = tag, Commit = commit, BuiltAt = builtAt }, UpdatedAtMs = Now() }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点从某个地址连上来了:查一次它的国家和运营商。已经查过同一个地址(并且没过期)就什么也不做;
|
||||
/// 私有地址不查;查询失败或服务关着,不影响别的,隔一阵再试。
|
||||
/// </summary>
|
||||
public async ValueTask ObserveAddressAsync(string nodeId, string kind, string? host, CancellationToken ct)
|
||||
{
|
||||
host = host?.Trim();
|
||||
if (string.IsNullOrEmpty(host) || !IsPublic(host) || !_inflight.TryAdd(nodeId, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureLoadedAsync(ct);
|
||||
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
|
||||
long now = Now();
|
||||
bool same = string.Equals(current.GeoHost, host, StringComparison.OrdinalIgnoreCase);
|
||||
bool fresh = current.Info.Country is not null && now - current.GeoAtMs < s_geoTtl.TotalMilliseconds;
|
||||
bool recentlyFailed = current.Info.Country is null && now - current.GeoAtMs < s_retryAfter.TotalMilliseconds;
|
||||
if (same && (fresh || recentlyFailed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GeoInfo? geo = await _geo.LookupAsync(host, ct);
|
||||
if (geo is null || geo.IsPrivate || string.IsNullOrEmpty(geo.Country))
|
||||
{
|
||||
// 记下这次没查到(地址和时间),免得每次心跳都去查
|
||||
await SaveAsync(current with { GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
||||
string country = geo.Country.ToUpperInvariant();
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inflight.TryRemove(nodeId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Probe 连上来或心跳报告它还连着:记下它的系统信息,并在后台查它的国家和运营商。出错只吞掉,不影响调用方。</summary>
|
||||
public void ObserveProbeInBackground(string probeId, string? remote, PlatformInfo? platform) =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await SetPlatformAsync(probeId, NodeKinds.Probe, platform, cts.Token);
|
||||
await ObserveAddressAsync(probeId, NodeKinds.Probe, remote, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
|
||||
{
|
||||
// 展示信息拿不到也没关系,下次再试
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Agent 打招呼(hello):记下它的系统信息,并用它的公开地址在后台查国家和运营商。</summary>
|
||||
public void ObserveAgentInBackground(string agentId, string? publicHost, PlatformInfo? platform) =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await SetPlatformAsync(agentId, NodeKinds.Agent, platform, cts.Token);
|
||||
await ObserveAddressAsync(agentId, NodeKinds.Agent, publicHost, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
|
||||
{
|
||||
// 同上
|
||||
}
|
||||
});
|
||||
|
||||
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
_cache.TryRemove(nodeId, out _);
|
||||
await _store.DeleteAsync(nodeId, ct);
|
||||
}
|
||||
|
||||
/// <summary>公网地址才值得查:回环、内网、CGNAT、链路本地、未指定的地址一律不发出去。域名放行(由查询服务自己解析)。</summary>
|
||||
public static bool IsPublic(string host)
|
||||
{
|
||||
if (!IPAddress.TryParse(host, out IPAddress? ip))
|
||||
{
|
||||
return host.Contains('.', StringComparison.Ordinal) && !host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) && !host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(ip) || ip.Equals(IPAddress.Any) || ip.Equals(IPAddress.IPv6Any) || ip.Equals(IPAddress.None))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ip.IsIPv4MappedToIPv6)
|
||||
{
|
||||
ip = ip.MapToIPv4();
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] b = ip.GetAddressBytes();
|
||||
return !(b[0] == 10
|
||||
|| (b[0] == 172 && b[1] is >= 16 and <= 31)
|
||||
|| (b[0] == 192 && b[1] == 168)
|
||||
|| (b[0] == 169 && b[1] == 254)
|
||||
|| (b[0] == 100 && b[1] is >= 64 and <= 127)
|
||||
|| b[0] == 0
|
||||
|| b[0] >= 224);
|
||||
}
|
||||
|
||||
byte[] v6 = ip.GetAddressBytes();
|
||||
bool linkLocal = v6[0] == 0xfe && (v6[1] & 0xc0) == 0x80;
|
||||
bool uniqueLocal = (v6[0] & 0xfe) == 0xfc;
|
||||
bool multicast = v6[0] == 0xff;
|
||||
return !(linkLocal || uniqueLocal || multicast);
|
||||
}
|
||||
|
||||
/// <summary>第一次用到时从库里读一次(只读一次,之后都在内存里)。</summary>
|
||||
private async ValueTask EnsureLoadedAsync(CancellationToken ct)
|
||||
{
|
||||
Task load = Volatile.Read(ref _load) ?? Interlocked.CompareExchange(ref _load, LoadAsync(), null) ?? _load!;
|
||||
await load.WaitAsync(ct);
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
foreach (NodeMeta meta in await _store.ListAsync(CancellationToken.None))
|
||||
{
|
||||
_cache.TryAdd(meta.Info.NodeId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
||||
{
|
||||
_cache[meta.Info.NodeId] = meta;
|
||||
await _store.SaveAsync(meta, ct);
|
||||
}
|
||||
|
||||
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
|
||||
private static NodeMeta Empty(string nodeId, string kind) => new(new NodeInfo(nodeId, kind, null, null, null, null, null, null), null, 0, 0);
|
||||
|
||||
/// <summary>节点自报的字符串只用于展示:去掉首尾空白,截断,没有内容就当没有。</summary>
|
||||
private static string? Clean(string? text, int max)
|
||||
{
|
||||
string trimmed = text?.Trim() ?? string.Empty;
|
||||
return trimmed.Length == 0 ? null : trimmed.Length > max ? trimmed[..max] : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,22 @@ public interface IAgentStore
|
||||
ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>节点的展示信息(系统、架构、国家、运营商)。</summary>
|
||||
public interface INodeMetaStore
|
||||
{
|
||||
ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask SaveAsync(NodeMeta meta, CancellationToken ct);
|
||||
|
||||
ValueTask DeleteAsync(string nodeId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>查一个 IP(或域名)的归属:国家、ASN、组织。实现负责超时和失败(失败返回 null,不抛)。</summary>
|
||||
public interface IGeoLookup
|
||||
{
|
||||
ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IJoinTokenStore
|
||||
{
|
||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
|
||||
@@ -40,6 +56,8 @@ public interface IJoinTokenStore
|
||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IProbeStore
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace Monitor.Backend.Application;
|
||||
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
|
||||
public sealed class TestService
|
||||
{
|
||||
private readonly double _phaseScale;
|
||||
private readonly ITestStore _tests;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
@@ -16,8 +17,9 @@ public sealed class TestService
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time)
|
||||
public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time, BackendOptions options)
|
||||
{
|
||||
_phaseScale = options.PhaseDurationScale;
|
||||
_tests = tests;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
@@ -52,7 +54,7 @@ public sealed class TestService
|
||||
return Result<TestRecord>.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs");
|
||||
}
|
||||
|
||||
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent);
|
||||
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent, _phaseScale);
|
||||
if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error))
|
||||
{
|
||||
return Result<TestRecord>.Fail(error);
|
||||
@@ -173,7 +175,7 @@ public sealed class TestService
|
||||
|
||||
public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted;
|
||||
|
||||
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent)
|
||||
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent, double phaseScale)
|
||||
{
|
||||
if (overrides is not null)
|
||||
{
|
||||
@@ -245,7 +247,7 @@ public sealed class TestService
|
||||
return Result<List<TestPhase>>.Fail(MonitorErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s");
|
||||
}
|
||||
|
||||
int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : phase.DurationSeconds;
|
||||
int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : Math.Max(1, (int)Math.Round(phase.DurationSeconds * phaseScale));
|
||||
phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary<string, JsonElement>(resolved.Values.Values), DurationSeconds = duration });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Monitor.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 3;
|
||||
public const int CurrentVersion = 5;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -55,6 +55,41 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 3");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 4)
|
||||
{
|
||||
// 节点的展示信息:系统、架构、国家、运营商;geo_host 是查询用的地址,只在 Backend 内部用
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("""
|
||||
CREATE TABLE node_meta (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
os TEXT,
|
||||
os_name TEXT,
|
||||
arch TEXT,
|
||||
country TEXT,
|
||||
isp_key TEXT,
|
||||
isp_name TEXT,
|
||||
geo_host TEXT,
|
||||
geo_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
) WITHOUT ROWID
|
||||
""");
|
||||
connection.Exec("PRAGMA user_version = 4");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 5)
|
||||
{
|
||||
// 节点自报的构建信息:版本、tag、提交、构建时间
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN version TEXT");
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN tag TEXT");
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN commit_id TEXT");
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN built_at TEXT");
|
||||
connection.Exec("PRAGMA user_version = 5");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct) =>
|
||||
_db.WriteAsync(c => c.Exec("UPDATE join_tokens SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct);
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) =>
|
||||
_db.WriteAsync(c => c.Exec("DELETE FROM join_tokens WHERE id = @id", ("@id", id)) == 1, ct);
|
||||
}
|
||||
|
||||
public sealed class SqliteProbeStore : IProbeStore
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend.Storage.Sqlite;
|
||||
|
||||
public sealed class SqliteNodeMetaStore : INodeMetaStore
|
||||
{
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
public SqliteNodeMetaStore(SqliteDatabase db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<NodeMeta>>(
|
||||
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),
|
||||
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 " +
|
||||
"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",
|
||||
("@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)),
|
||||
ct);
|
||||
|
||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(c => c.Exec("DELETE FROM node_meta WHERE node_id = @id", ("@id", nodeId)), ct);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public static class StorageServiceCollectionExtensions
|
||||
services.AddSingleton<ITestStore, SqliteTestStore>();
|
||||
services.AddSingleton<IEventStore, SqliteEventStore>();
|
||||
services.AddSingleton<IHostStore, SqliteHostStore>();
|
||||
services.AddSingleton<INodeMetaStore, SqliteNodeMetaStore>();
|
||||
services.AddSingleton<IAuthStore, SqliteAuthStore>();
|
||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||
|
||||
@@ -38,6 +38,16 @@ public static class BackendHost
|
||||
IServiceCollection services = builder.Services;
|
||||
services.AddSingleton(time ?? TimeProvider.System);
|
||||
services.AddMonitorSqlite(options.DataDirectory);
|
||||
// 国家和运营商:Backend 用节点的来源地址查一次(Probe、Agent 不访问任何外部服务);地址留空就不查
|
||||
if (string.IsNullOrWhiteSpace(options.GeoLookupUrl))
|
||||
{
|
||||
services.AddSingleton<IGeoLookup, NoGeoLookup>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IGeoLookup>(new HttpGeoLookup(options.GeoLookupUrl));
|
||||
}
|
||||
|
||||
services.AddMonitorApplication(options);
|
||||
services.AddMonitorGateway();
|
||||
services.AddMonitorApi();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// 用 IP 归属查询服务(<c>GET {地址}/lookup?ip=…</c>)查国家和运营商。
|
||||
/// 失败(超时、网络不通、服务返回错误)一律返回 null,不往外抛:展示信息没有也不影响别的功能。
|
||||
/// </summary>
|
||||
public sealed class HttpGeoLookup : IGeoLookup, IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public HttpGeoLookup(string baseUrl)
|
||||
{
|
||||
_http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(6) };
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyMonitor");
|
||||
}
|
||||
|
||||
public async ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _http.GetAsync("lookup?ip=" + Uri.EscapeDataString(host), ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using JsonDocument doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
|
||||
return Parse(doc.RootElement);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>只取国家代码、ASN、组织名;其余字段(城市、经纬度、网段……)一概不读。</summary>
|
||||
public static GeoInfo? Parse(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("found", out JsonElement found) && found.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isPrivate = root.TryGetProperty("is_private", out JsonElement priv) && priv.ValueKind == JsonValueKind.True;
|
||||
string? country = root.TryGetProperty("country_region", out JsonElement region) && region.TryGetProperty("iso_code", out JsonElement code) && code.ValueKind == JsonValueKind.String ? code.GetString() : null;
|
||||
int? asn = null;
|
||||
string? organization = null;
|
||||
if (root.TryGetProperty("network", out JsonElement network))
|
||||
{
|
||||
if (network.TryGetProperty("asn", out JsonElement asnValue) && asnValue.TryGetInt32(out int number))
|
||||
{
|
||||
asn = number;
|
||||
}
|
||||
|
||||
string? isp = network.TryGetProperty("isp", out JsonElement ispValue) && ispValue.ValueKind == JsonValueKind.String ? ispValue.GetString() : null;
|
||||
string? org = network.TryGetProperty("organization", out JsonElement orgValue) && orgValue.ValueKind == JsonValueKind.String ? orgValue.GetString() : null;
|
||||
organization = string.IsNullOrWhiteSpace(isp) ? org : isp;
|
||||
}
|
||||
|
||||
return new GeoInfo(country, asn, organization, isPrivate);
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>不查(<c>Monitor__GeoLookupUrl</c> 留空)。</summary>
|
||||
public sealed class NoGeoLookup : IGeoLookup
|
||||
{
|
||||
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct) => ValueTask.FromResult<GeoInfo?>(null);
|
||||
}
|
||||
@@ -256,7 +256,7 @@ public sealed class AgentEndToEndTests
|
||||
string? startFailure = null;
|
||||
for (int attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } }));
|
||||
accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } }));
|
||||
if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||
{
|
||||
break;
|
||||
@@ -291,7 +291,7 @@ public sealed class AgentEndToEndTests
|
||||
Assert.Equal(4, probe.Phases.Count);
|
||||
Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind));
|
||||
|
||||
// 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(10 s、1000 B 的包)各被 Probe 切成多个列块
|
||||
// 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(5 s、1000 B 的包)里 100 Mbit/s 的那一段有 6 万多条记录,被 Probe 切成两个列块
|
||||
Assert.True(agent.App.Counters.Fragments > 4);
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == "udp_train") == agent.App.Counters.Fragments,
|
||||
|
||||
@@ -58,6 +58,39 @@ public sealed class ApiBasicsTests
|
||||
Assert.Equal("no-store", ok.Headers.CacheControl!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true }));
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
Assert.False(login.Headers.Contains("Set-Cookie"));
|
||||
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>();
|
||||
Assert.False(string.IsNullOrEmpty(session));
|
||||
|
||||
// 之后当 API 令牌那样用 Bearer;不是 Cookie,所以不需要 CSRF,也能做非 GET 操作
|
||||
using var me = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
|
||||
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage meResponse = await client.SendAsync(me);
|
||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||
Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
|
||||
|
||||
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
|
||||
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage created = await client.SendAsync(create);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
|
||||
// 退出之后这个会话令牌就失效
|
||||
using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/admin/auth/logout");
|
||||
logout.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage loggedOut = await client.SendAsync(logout);
|
||||
Assert.Equal(HttpStatusCode.NoContent, loggedOut.StatusCode);
|
||||
using var after = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
|
||||
after.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage afterResponse = await client.SendAsync(after);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, afterResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
|
||||
{
|
||||
|
||||
@@ -192,6 +192,8 @@ public class BackendFixture : IAsyncLifetime
|
||||
options.InitialAdminUser = AdminUser;
|
||||
options.InitialAdminPassword = AdminPassword;
|
||||
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
|
||||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||||
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
||||
});
|
||||
await App.StartAsync();
|
||||
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
|
||||
@@ -83,6 +83,14 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id);
|
||||
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked")));
|
||||
|
||||
// 吊销之后记录还在(显示「已吊销」);purge=true 才是彻底删除
|
||||
tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
|
||||
Assert.Contains(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
|
||||
using HttpResponseMessage purge = await _backend.Admin.DeleteAsync($"/api/admin/join-tokens/{id}?purge=true");
|
||||
Assert.Equal(HttpStatusCode.NoContent, purge.StatusCode);
|
||||
tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
|
||||
Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Tests;
|
||||
|
||||
public sealed partial class NodeInfoTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(9808, "China Mobile Communications Group", "mobile", "中国移动")]
|
||||
[InlineData(4134, "Chinanet", "telecom", "中国电信")]
|
||||
[InlineData(4837, "China Unicom Backbone", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Mobile Communications Corporation", "mobile", "中国移动")]
|
||||
[InlineData(null, "CHINANET-BACKBONE", "telecom", "中国电信")]
|
||||
[InlineData(null, "China Unicom Beijing Province Network", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Broadnet Corporation", "broadnet", "中国广电")]
|
||||
public void IspClassifier_RecognizesTheChineseCarriers(int? asn, string org, string key, string name)
|
||||
{
|
||||
Assert.Equal((key, name), IspClassifier.Classify(asn, org));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IspClassifier_OtherCarriersKeepAShortenedOrganizationName()
|
||||
{
|
||||
(string key, string name) = IspClassifier.Classify(37963, "Hangzhou Alibaba Advertising Co.,Ltd.");
|
||||
Assert.Equal("other", key);
|
||||
Assert.Equal("Hangzhou Alibaba Advertising", name);
|
||||
Assert.Equal("Google", IspClassifier.Classify(15169, "Google LLC").Name);
|
||||
Assert.True(IspClassifier.Classify(1, new string('x', 80)).Name.Length <= 28);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("8.8.8.8", true)]
|
||||
[InlineData("223.5.5.5", true)]
|
||||
[InlineData("2001:4860:4860::8888", true)]
|
||||
[InlineData("example.com", true)]
|
||||
[InlineData("127.0.0.1", false)]
|
||||
[InlineData("10.1.2.3", false)]
|
||||
[InlineData("172.16.0.1", false)]
|
||||
[InlineData("172.32.0.1", true)]
|
||||
[InlineData("192.168.1.10", false)]
|
||||
[InlineData("169.254.1.1", false)]
|
||||
[InlineData("100.64.0.1", false)]
|
||||
[InlineData("0.0.0.0", false)]
|
||||
[InlineData("::1", false)]
|
||||
[InlineData("fe80::1", false)]
|
||||
[InlineData("fd00::1", false)]
|
||||
[InlineData("::ffff:192.168.1.1", false)]
|
||||
[InlineData("localhost", false)]
|
||||
[InlineData("printer.local", false)]
|
||||
public void OnlyPublicAddressesAreEverSentToTheLookupService(string host, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, NodeInfoService.IsPublic(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_LooksAPublicAddressUpOnce_KeepsOnlyCountryAndIsp_AndNeverQueriesPrivateOnes()
|
||||
{
|
||||
var geo = new FakeGeo(new GeoInfo("cn", 9808, "China Mobile Communications Group", false));
|
||||
var store = new MemoryStore();
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(store, geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None);
|
||||
Assert.Empty(geo.Calls);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
|
||||
NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("CN", "mobile", "中国移动"), (info.Country, info.IspKey, info.IspName));
|
||||
|
||||
// 展示用的信息里没有地址:整个对象序列化出来,找不到任何 IP 形状的字符串
|
||||
Assert.DoesNotMatch(Ipv4(), info.ToString());
|
||||
|
||||
// 地址变了才重新查;过了一周也重新查
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
time.Advance(TimeSpan.FromDays(8));
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(3, geo.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_FailedLookups_AreNotRetriedForAWhile_AndNeverThrow()
|
||||
{
|
||||
var geo = new FakeGeo(null);
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(new MemoryStore(), geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
time.Advance(TimeSpan.FromMinutes(11));
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_PlatformIsSavedAndSurvivesARestart()
|
||||
{
|
||||
var store = new MemoryStore();
|
||||
var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
|
||||
|
||||
var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("linux", "Debian 12", "arm64"), (info.Os, info.OsName, info.Arch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeoParser_ReadsOnlyCountryAsnAndOrganization()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
{"ip":"223.5.5.5","found":true,"is_private":false,
|
||||
"country_region":{"iso_code":"CN","subdivisions":[{"names":{"zh-CN":"浙江省"}}]},
|
||||
"city":{"names":{"zh-CN":"杭州市"}},
|
||||
"location":{"latitude":30.25,"longitude":120.21},
|
||||
"network":{"cidr":"223.4.0.0/14","asn":37963,"organization":"Hangzhou Alibaba Advertising Co.,Ltd.","isp":""}}
|
||||
""");
|
||||
GeoInfo? geo = HttpGeoLookup.Parse(doc.RootElement);
|
||||
Assert.Equal(new GeoInfo("CN", 37963, "Hangzhou Alibaba Advertising Co.,Ltd.", false), geo);
|
||||
Assert.Null(HttpGeoLookup.Parse(JsonDocument.Parse("""{"found":false}""").RootElement));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")]
|
||||
private static partial Regex Ipv4();
|
||||
|
||||
private sealed class FakeGeo : IGeoLookup
|
||||
{
|
||||
private readonly GeoInfo? _result;
|
||||
|
||||
public FakeGeo(GeoInfo? result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
|
||||
{
|
||||
Calls.Add(host);
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemoryStore : INodeMetaStore
|
||||
{
|
||||
private readonly Dictionary<string, NodeMeta> _items = [];
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) => ValueTask.FromResult<IReadOnlyList<NodeMeta>>([.. _items.Values]);
|
||||
|
||||
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
||||
{
|
||||
_items[meta.Info.NodeId] = meta;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
_items.Remove(nodeId);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
|
||||
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed partial class NodePrivacyTests
|
||||
{
|
||||
private static readonly string[] s_forbiddenKeys = ["ip", "city", "asn", "latitude", "longitude", "cidr", "remote", "public_host", "geo_host", "host_name", "hostname"];
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public NodePrivacyTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicEndpoints_NeverExposeAnAddress_AndNodesCarryOnlyPlatformCountryAndIsp()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
// 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步)
|
||||
JsonNode? agentNode = null;
|
||||
JsonNode? probeNode = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id);
|
||||
probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == probe.ProbeId);
|
||||
return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(40),
|
||||
"the agent (hello) and the probe (agent list sync) both report their platform");
|
||||
Assert.Equal("agent", agentNode!["kind"]!.GetValue<string>());
|
||||
Assert.Equal("probe", probeNode!["kind"]!.GetValue<string>());
|
||||
|
||||
string[] paths =
|
||||
[
|
||||
"/api/public/nodes",
|
||||
"/api/public/hosts",
|
||||
"/api/public/overview?range=1h&metrics=rtt.p50",
|
||||
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}",
|
||||
"/api/public/catalog/plans",
|
||||
];
|
||||
foreach (string path in paths)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
||||
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode}");
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotMatch(Ipv4(), body);
|
||||
Assert.DoesNotContain("::1", body, StringComparison.Ordinal);
|
||||
foreach (string key in KeysOf(JsonNode.Parse(body)))
|
||||
{
|
||||
Assert.DoesNotContain(key, s_forbiddenKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildInfo_IsHiddenFromVisitorsUntilTheSwitchIsOn_AndAlwaysVisibleToAdmins()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
|
||||
// 默认关:访客看不到版本和 tag,「关于」里只有 show_build: false
|
||||
JsonNode about = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.False(about["show_build"]!.GetValue<bool>());
|
||||
Assert.Null(about["backend"]);
|
||||
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
Assert.All(hidden["items"]!.AsArray(), n =>
|
||||
{
|
||||
Assert.Null(n!["version"]);
|
||||
Assert.Null(n["tag"]);
|
||||
});
|
||||
|
||||
// 管理端一直看得到:后端自己的版本和每个节点上报的版本
|
||||
JsonNode admin = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
Assert.False(string.IsNullOrEmpty(admin["backend"]!["version"]!.GetValue<string>()));
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"))["nodes"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == agent.Id && n["version"] is not null),
|
||||
TimeSpan.FromSeconds(30),
|
||||
"the agent reports its version in hello");
|
||||
|
||||
using HttpResponseMessage on = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = true }, "application/merge-patch+json"));
|
||||
on.EnsureSuccessStatusCode();
|
||||
try
|
||||
{
|
||||
JsonNode shown = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.True(shown["show_build"]!.GetValue<bool>());
|
||||
Assert.False(string.IsNullOrEmpty(shown["backend"]!["version"]!.GetValue<string>()));
|
||||
Assert.Null(shown["backend"]!["commit"]); // 访客只看版本、tag、构建时间
|
||||
JsonNode visible = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
JsonNode self = visible["items"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!;
|
||||
Assert.False(string.IsNullOrEmpty(self["version"]!.GetValue<string>()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
using HttpResponseMessage off = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = false }, "application/merge-patch+json"));
|
||||
off.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> KeysOf(JsonNode? node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
foreach ((string key, JsonNode? value) in obj)
|
||||
{
|
||||
yield return key;
|
||||
foreach (string inner in KeysOf(value))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case JsonArray array:
|
||||
foreach (JsonNode? item in array)
|
||||
{
|
||||
foreach (string inner in KeysOf(item))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
|
||||
private static partial Regex Ipv4();
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public sealed class ProbeEndToEndTests
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } }));
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } }));
|
||||
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
@@ -78,7 +78,7 @@ public sealed class ProbeEndToEndTests
|
||||
TimeSpan.FromSeconds(90));
|
||||
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "50 and 100 Mbit/s phases are split into several fragments");
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments");
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -85,6 +85,15 @@
|
||||
|
||||
`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/about`:`{show_build, backend}`。`show_build` 关着时 `backend` 是 `null`;打开后 `backend` 是 `{version, tag, built_at}`(后端自己的版本、tag、构建时间,不含提交号)。
|
||||
|
||||
- 系统和架构是节点自报的:Agent 在 `fleet.hello` 里,Probe 随列表同步(`monitor.probe.agents` 的 `platform`)告诉 Agent,Agent 再放进心跳的 `probes[]`。只有粗粒度的名字,没有主机名、内核版本、CPU 型号。
|
||||
- 国家和运营商由 **Backend** 用节点的来源地址向 IP 归属服务查一次(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`,留空就不查):Probe 用 Agent 看到的来源 IP,Agent 用它的公开地址。**Probe 和 Agent 不访问任何外部服务。** 只查公网地址(回环、内网、CGNAT、链路本地一律不发出去);同一个地址一周内只查一次,失败 10 分钟后再试;查到的只留国家代码和运营商,地址只存在 Backend 内部的 `node_meta.geo_host`,不对外。
|
||||
|
||||
### 2.4 测试结果与目录
|
||||
|
||||
- `GET /api/public/tests/{test_id}`:包括计划、参数、状态、阶段,`metrics` 与 `scores`,`series`(例如码率–丢包曲线),以及置信说明 `caveats`。
|
||||
@@ -125,7 +134,8 @@
|
||||
| GET | `/api/admin/releases` | 各应用、各通道的当前版本,以及节点的版本分布(由 `Fleet.Server.Releases` 提供) |
|
||||
| POST | `/api/admin/releases/{app}/{channel}/rollout` | `{version, batches:[{labels?, count?}], soak_minutes}`:分批灰度;只能选择已经签名的清单 |
|
||||
| POST | `/api/admin/releases/{app}/approve` | 对 `policy = notify` 的节点批准更新 `{version, agents?}` |
|
||||
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、保留期等 |
|
||||
| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期等 |
|
||||
| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统(管理端「系统 → 版本」) |
|
||||
| GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 |
|
||||
|
||||
任务的请求体示例:
|
||||
|
||||
@@ -9,6 +9,8 @@ scripts/publish.sh <agent|probe|backend|fleet-enroll> <rid> → backend/art
|
||||
scripts/package.sh <agent|probe> <rid> → backend/artifacts/packages/monitor-<role>-<version>-<rid>.tar.gz(附 .sha256)
|
||||
```
|
||||
|
||||
构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MONITOR_TAG`、`MONITOR_COMMIT`、`MONITOR_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器的 Fastlane 构建用同名的 `--build-arg`);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`)。
|
||||
|
||||
| rid | 目标 | 构建方式 |
|
||||
| --- | --- | --- |
|
||||
| `linux-x64`、`linux-arm64` | Debian/Ubuntu glibc 服务器 | Windows CI 主机通过 AotAnywhere 交叉编译 |
|
||||
@@ -174,6 +176,8 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2
|
||||
- VPS:`/etc/systemd/system/monitor-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/monitor`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。
|
||||
- 日志进 journal:`journalctl -u monitor-agent`、`journalctl --user -u monitor-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。
|
||||
|
||||
**国家和运营商**:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。
|
||||
|
||||
**从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `Monitor__CorsOrigins` 放行前端的来源(见 [web/README.md](../web/README.md))。
|
||||
|
||||
实测(Probe 在广东移动家宽,Agent 在日本 IIJ):
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
|
||||
|
||||
WORKDIR /src/backend
|
||||
# 构建信息:CI 通过 --build-arg 传入;不传就是空
|
||||
ARG MONITOR_TAG=
|
||||
ARG MONITOR_COMMIT=
|
||||
ARG MONITOR_BUILT_AT=
|
||||
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
|
||||
COPY restore/backend/ ./
|
||||
RUN dotnet restore agent/src/Monitor.Agent -r linux-x64 --artifacts-path /artifacts \
|
||||
&& dotnet restore fleet/tools/fleet-enroll -r linux-x64 --artifacts-path /artifacts
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
|
||||
|
||||
WORKDIR /src/backend
|
||||
# 构建信息:CI 通过 --build-arg 传入;不传就是空
|
||||
ARG MONITOR_TAG=
|
||||
ARG MONITOR_COMMIT=
|
||||
ARG MONITOR_BUILT_AT=
|
||||
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
|
||||
COPY restore/backend/ ./
|
||||
RUN dotnet restore probe/src/Monitor.Probe -r linux-x64 --artifacts-path /artifacts
|
||||
|
||||
|
||||
+8
-2
@@ -16,17 +16,23 @@ case "$project" in
|
||||
*) echo "unknown project: $project" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# 构建信息(程序里显示的 tag、提交、构建时间):没在 git 仓库里就留空
|
||||
export MONITOR_TAG="${MONITOR_TAG:-$(git -C "$root" describe --tags --always --dirty 2>/dev/null || true)}"
|
||||
export MONITOR_COMMIT="${MONITOR_COMMIT:-$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)}"
|
||||
export MONITOR_BUILT_AT="${MONITOR_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||
build_env=(-e MONITOR_TAG -e MONITOR_COMMIT -e MONITOR_BUILT_AT)
|
||||
|
||||
out="backend/artifacts/publish/$project/$rid"
|
||||
args=(publish "$csproj" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false -o "/src/$out" --artifacts-path /artifacts -nologo -v q)
|
||||
|
||||
case "$rid" in
|
||||
linux-musl-x64)
|
||||
# SDK 镜像的版本可以比 global.json 新(rollForward = latestFeature)
|
||||
docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src \
|
||||
docker run --rm "${build_env[@]}" -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src \
|
||||
mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot dotnet "${args[@]}"
|
||||
;;
|
||||
linux-x64)
|
||||
docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot dotnet "${args[@]}"
|
||||
docker run --rm "${build_env[@]}" -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot dotnet "${args[@]}"
|
||||
;;
|
||||
linux-musl-arm64|linux-arm64)
|
||||
echo "$rid needs an arm64 Alpine builder (native or qemu): run this script on an arm64 host or in CI" >&2
|
||||
|
||||
+7
-4
@@ -10,18 +10,21 @@
|
||||
|
||||
```sh
|
||||
npm install # 在 web/ 下一次装好两个工程的依赖(npm workspaces)
|
||||
npm run dev # 两个一起启动
|
||||
npm run dev # 两个一起启动:展示端 http://localhost:5173/ ,管理端 http://localhost:5173/admin/(也可以直接开 5175)
|
||||
BACKEND_URL=https://mo.oeo.one npm run dev:display # http://127.0.0.1:5173/
|
||||
BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5174/admin/
|
||||
BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5175/admin/
|
||||
# 或者把 .env.example 复制成 .env.local,写好 BACKEND_URL,之后直接 npm run dev:display
|
||||
npm run build # 各自输出到 display/dist 和 admin/dist
|
||||
npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts
|
||||
npm run lint:css # 颜色只能写在 shared/theme/tokens.css,其他样式一律引用令牌
|
||||
```
|
||||
|
||||
连别的后端(本机跑前端、连线上的):点页面右上角(管理端登录页右下角)的插头图标,填后端地址和 API 令牌(展示端:站点私有时需要只读令牌;管理端:admin 范围的令牌),保存后刷新。设置存在浏览器里;跨域时不带 Cookie,所以后端要设 `Monitor__CorsOrigins=<这个前端的来源>`(如 `http://localhost:5173`)。也可以用构建时的 `VITE_API_BASE` 给默认地址。
|
||||
连别的后端(本机跑前端、连线上的):登录页左边选后端(这个页面自己所在的、保存过的,或者「添加后端」),右边都是用户名和密码;添加后端填地址、用户名、密码,登录成功才保存。跨域用不了 Cookie,所以登录时后端会返回会话令牌(`return_session`),前端存在浏览器里、之后每个请求带上,12 小时过期、退出登录即作废;用户看不到也不需要管它。后端需要放行这个页面的来源(`Monitor__CorsOrigins`)。展示端和管理端侧栏的插头图标是同一个切换器。
|
||||
|
||||
约定:
|
||||
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。
|
||||
- 时间范围(15 分钟 … 7 天)只在线路详情页;总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。
|
||||
- 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。
|
||||
- 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。
|
||||
- 图表在数据刷新(轮询、实时)时原地更新,不重建;拖选区时只在本地更新,松手才取数。
|
||||
- 总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。
|
||||
- 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>线路质量 · 管理</title>
|
||||
<title>MyMonitor · 管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+68
-24
@@ -1,7 +1,7 @@
|
||||
import { FlaskConical, ListChecks, LogOut, Radar, Server, Settings } from 'lucide-react'
|
||||
import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { isRemote } from '@shared/api/source'
|
||||
import { activateSource, getBase, listSources } from '@shared/api/source'
|
||||
import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
|
||||
import { useAuth } from './auth'
|
||||
import { AgentDetail } from './pages/AgentDetail'
|
||||
@@ -11,22 +11,34 @@ import { System } from './pages/System'
|
||||
import { Tasks } from './pages/Tasks'
|
||||
import { Tests } from './pages/Tests'
|
||||
|
||||
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */
|
||||
function Login() {
|
||||
const { login, loginWithToken } = useAuth()
|
||||
const remote = isRemote()
|
||||
const [token, setTokenText] = useState('')
|
||||
const { login } = useAuth()
|
||||
const active = getBase()
|
||||
const saved = listSources()
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const choose = (base: string) => {
|
||||
setAdding(false)
|
||||
setError('')
|
||||
setPassword('')
|
||||
if (base !== active) {
|
||||
activateSource(base)
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (remote) await loginWithToken(token)
|
||||
else await login(username, password)
|
||||
await login(username, password, adding ? url : undefined)
|
||||
} catch (err) {
|
||||
setError(describeError(err))
|
||||
} finally {
|
||||
@@ -34,35 +46,67 @@ function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))]
|
||||
const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<form className="card stack" onSubmit={submit}>
|
||||
<form className="card login-split" onSubmit={submit}>
|
||||
<aside className="login-side">
|
||||
<div>
|
||||
<h1>线路质量</h1>
|
||||
<h1>MyMonitor</h1>
|
||||
<p className="muted">管理端</p>
|
||||
</div>
|
||||
{remote ? (
|
||||
<Field label="管理令牌" hint="在管理端「系统 → API 令牌」里创建,范围选 admin">
|
||||
<input className="input" type="password" value={token} onChange={(e) => setTokenText(e.target.value)} autoComplete="off" autoFocus required />
|
||||
<div className="src-list" role="listbox" aria-label="后端">
|
||||
{rows.map((row) => {
|
||||
const on = !adding && row.base === active
|
||||
return (
|
||||
<button key={row.base || 'page'} type="button" role="option" aria-selected={on} className={`src-pick${on ? ' on' : ''}`} onClick={() => choose(row.base)}>
|
||||
<span className="src-dot" />
|
||||
<span className="src-text">
|
||||
<b>{row.label}</b>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={adding}
|
||||
className={`src-pick${adding ? ' on' : ''}`}
|
||||
onClick={() => {
|
||||
setAdding(true)
|
||||
setUrl('')
|
||||
setPassword('')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="src-text">
|
||||
<b>添加后端</b>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="login-main stack">
|
||||
<h2>{title}</h2>
|
||||
{adding ? (
|
||||
<Field label="后端地址">
|
||||
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
) : null}
|
||||
<Field label="用户名">
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus required />
|
||||
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
登录
|
||||
{adding ? '保存并登录' : '登录'}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
<div style={{ position: 'fixed', right: 16, bottom: 16 }}>
|
||||
<SourceButton tokenLabel="管理令牌" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,7 +132,7 @@ export function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<a className="brand" href="/">
|
||||
线路质量
|
||||
MyMonitor
|
||||
</a>
|
||||
{NAV.map((group) => (
|
||||
<div key={group.section} style={{ display: 'contents' }}>
|
||||
@@ -102,6 +146,7 @@ export function App() {
|
||||
</div>
|
||||
))}
|
||||
<div className="spacer" />
|
||||
<SourceButton variant="status" />
|
||||
<div className="me">
|
||||
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="grow">
|
||||
@@ -110,7 +155,6 @@ export function App() {
|
||||
{me.scope}
|
||||
</div>
|
||||
</span>
|
||||
<SourceButton tokenLabel="管理令牌" />
|
||||
<ThemeToggle />
|
||||
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
|
||||
<LogOut size={18} />
|
||||
|
||||
@@ -69,6 +69,27 @@
|
||||
background: var(--app-card);
|
||||
}
|
||||
|
||||
/* 用户名太长时省略,不要和右边的按钮叠在一起 */
|
||||
.me {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.me .grow {
|
||||
flex: 1 1 100px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.me .grow > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.me .btn,
|
||||
.me .avatar {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -192,3 +213,45 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录页:左边选后端,右边输入凭据 */
|
||||
.login .card.login-split {
|
||||
width: min(760px, 100%);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 24px;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
.login-main {
|
||||
padding: 32px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.login-main h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login .card.login-split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.login-side {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-main {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -1,14 +1,13 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api, ApiError, setCsrf } from '@shared/api/client'
|
||||
import { isRemote, setToken } from '@shared/api/source'
|
||||
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
|
||||
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
|
||||
import type { LoginResponse, MeDto } from '@shared/api/types'
|
||||
|
||||
interface Auth {
|
||||
me: MeDto | null
|
||||
ready: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
/** 连别的后端时用 API 令牌登录(不用 Cookie)。 */
|
||||
loginWithToken: (token: string) => Promise<void>
|
||||
/** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */
|
||||
login: (username: string, password: string, base?: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -31,14 +30,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
.finally(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const r = await api.post<LoginResponse>('/api/admin/auth/login', { username, password })
|
||||
setCsrf(r.csrf_token)
|
||||
setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token })
|
||||
}, [])
|
||||
|
||||
const loginWithToken = useCallback(async (token: string) => {
|
||||
setToken(token)
|
||||
const login = useCallback(async (username: string, password: string, newBase?: string) => {
|
||||
const base = newBase ? normalizeBase(newBase) : getBase()
|
||||
if (base) {
|
||||
// 别的后端:跨域用不了 Cookie,登录后拿回会话令牌存进浏览器(对用户不可见)
|
||||
const session = await loginTo(base, username, password)
|
||||
setSource(base, session)
|
||||
try {
|
||||
const m = await api.get<MeDto>('/api/admin/auth/me')
|
||||
setCsrf(m.csrf_token ?? null)
|
||||
@@ -47,19 +44,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setToken('')
|
||||
throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
const r = await api.post<LoginResponse>('/api/admin/auth/login', { username, password })
|
||||
setCsrf(r.csrf_token)
|
||||
setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token })
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
if (isRemote()) setToken('')
|
||||
else await api.post('/api/admin/auth/logout')
|
||||
await api.post('/api/admin/auth/logout')
|
||||
} catch {
|
||||
// 会话已经失效也没关系,下面照样清掉本地的
|
||||
} finally {
|
||||
setToken('')
|
||||
setCsrf(null)
|
||||
setMe(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <AuthContext.Provider value={{ me, ready, login, loginWithToken, logout }}>{children}</AuthContext.Provider>
|
||||
return <AuthContext.Provider value={{ me, ready, login, logout }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): Auth {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import '@shared/theme/base.css'
|
||||
import { configureSource } from '@shared/api/source'
|
||||
import { applyStoredTheme } from '@shared/hooks'
|
||||
import { ToastProvider } from '@shared/ui'
|
||||
import { ErrorBoundary, ToastProvider } from '@shared/ui'
|
||||
import { App } from './App'
|
||||
import { AuthProvider } from './auth'
|
||||
import './admin.css'
|
||||
@@ -15,11 +15,13 @@ applyStoredTheme()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Ban, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
|
||||
import { fmtAgo, fmtTime } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, guessPlatform, Segmented, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
|
||||
active: { label: '已启用', tone: 'ok' },
|
||||
@@ -45,11 +46,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
>
|
||||
<div className="stack">
|
||||
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
|
||||
<Field label="在要加入的 VPS 上运行">
|
||||
<pre className="code">{created.install_command}</pre>
|
||||
<div>
|
||||
<CopyButton text={created.install_command} label="复制安装命令" />
|
||||
</div>
|
||||
<Field label="在要加入的机器上运行">
|
||||
<CommandTabs
|
||||
storageKey="monitor.install_tab"
|
||||
initial={guessPlatform()}
|
||||
tabs={[
|
||||
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
|
||||
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
|
||||
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="令牌">
|
||||
<pre className="code">{created.plaintext}</pre>
|
||||
@@ -131,10 +137,12 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
export function Agents() {
|
||||
const navigate = useNavigate()
|
||||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
|
||||
const infos = useNodeInfos()
|
||||
const tokens = useQuery(() => api.get<Page<JoinTokenDto>>('/api/admin/join-tokens'), [])
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [revoking, setRevoking] = useState<JoinTokenDto>()
|
||||
const [deleting, setDeleting] = useState<JoinTokenDto>()
|
||||
const { run } = useAction()
|
||||
|
||||
const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter)
|
||||
@@ -187,7 +195,12 @@ export function Agents() {
|
||||
<td>
|
||||
<AgentStatus agent={a} />
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{a.name}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<span className="row" style={{ gap: 8 }}>
|
||||
{a.name}
|
||||
<NodeTag info={infos.get(a.id)} />
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono muted">{a.public_host || '—'}</td>
|
||||
<td className="muted">{a.agent_version ?? '—'}</td>
|
||||
<td>
|
||||
@@ -240,12 +253,18 @@ export function Agents() {
|
||||
<Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip>
|
||||
</td>
|
||||
<td className="r">
|
||||
<span className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
|
||||
{state === '可用' ? (
|
||||
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
|
||||
<Trash2 size={14} />
|
||||
<Ban size={14} />
|
||||
吊销
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -271,6 +290,20 @@ export function Agents() {
|
||||
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
|
||||
</Confirm>
|
||||
) : null}
|
||||
{deleting ? (
|
||||
<Confirm
|
||||
title="删除加入令牌"
|
||||
danger
|
||||
confirmText="删除"
|
||||
onClose={() => setDeleting(undefined)}
|
||||
onConfirm={async () => {
|
||||
await run(() => api.del(`/api/admin/join-tokens/${deleting.id}?purge=true`), '已删除')
|
||||
tokens.reload()
|
||||
}}
|
||||
>
|
||||
从列表里彻底删除这条记录。{deleting.revoked_at || deleting.uses >= deleting.max_uses || Date.parse(deleting.expires_at) < Date.now() ? '它已经不能再用了。' : '它现在还能用,删除之后会立刻失效。'}已经注册的 Agent 不受影响。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { api } from '@shared/api/client'
|
||||
import type { 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.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
|
||||
@@ -84,6 +85,7 @@ type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto }
|
||||
|
||||
export function Probes() {
|
||||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
|
||||
const infos = useNodeInfos()
|
||||
const { run } = useAction()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [renaming, setRenaming] = useState<ProbeDto>()
|
||||
@@ -123,6 +125,7 @@ export function Probes() {
|
||||
<div key={p.id} className="card">
|
||||
<div className="row wrap">
|
||||
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
|
||||
<NodeTag info={infos.get(p.id)} />
|
||||
{p.revoked ? <Chip tone="bad">已吊销</Chip> : <Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}</Chip>}
|
||||
<span className="mono faint">{p.id}</span>
|
||||
<span className="right row wrap">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { ApiTokenDto, CreatedApiTokenDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
|
||||
import type { AgentDto, ApiTokenDto, BuildInfoDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
|
||||
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
|
||||
import { fmtNum, fmtTime } from '@shared/format'
|
||||
import { translateSetting } from '@shared/i18n'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
type Tab = 'settings' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
|
||||
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
|
||||
|
||||
function Settings() {
|
||||
const settings = useQuery(() => api.get<Page<SettingDto>>('/api/admin/settings'), [])
|
||||
@@ -76,6 +77,77 @@ function Settings() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 一行:名字、类型、版本、tag、提交、构建时间、系统。没有的写「—」。 */
|
||||
function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string }) {
|
||||
const dash = <span className="faint">—</span>
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ fontWeight: 600 }}>{name}</td>
|
||||
<td>
|
||||
<Chip>{kind}</Chip>
|
||||
</td>
|
||||
<td className="mono">{version || dash}</td>
|
||||
<td className="mono">{tag || dash}</td>
|
||||
<td className="mono">{commit || dash}</td>
|
||||
<td className="muted">{fmtBuiltAt(builtAt) || dash}</td>
|
||||
<td className="muted">{platform || dash}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的(它们没升级就能看出来)。 */
|
||||
function Versions() {
|
||||
const info = useQuery(() => api.get<BuildInfoDto>('/api/admin/build-info'), [], 15_000)
|
||||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 30_000)
|
||||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 30_000)
|
||||
const names = new Map<string, string>([...(agents.data?.items ?? []).map((a) => [a.id, a.name] as const), ...(probes.data?.items ?? []).map((p) => [p.id, p.name] as const)])
|
||||
const back = info.data?.backend
|
||||
const nodes = info.data?.nodes ?? []
|
||||
// 以后端的版本为准,节点的版本不一样就标出来(升级了一半的时候一眼能看到)
|
||||
const mismatch = (v?: string | null) => Boolean(v && back && v !== back.version)
|
||||
return (
|
||||
<div className="card">
|
||||
<ErrorNotice error={info.error} />
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>版本</th>
|
||||
<th>tag</th>
|
||||
<th>提交</th>
|
||||
<th>构建时间</th>
|
||||
<th>系统</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{back ? <BuildRow name="后端" kind="backend" version={back.version} tag={back.tag} commit={back.commit} builtAt={back.built_at} /> : null}
|
||||
<BuildRow name="前端" kind="web" version={frontendBuild.version} tag={frontendBuild.tag} commit={frontendBuild.commit} builtAt={frontendBuild.builtAt} />
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td style={{ fontWeight: 600 }}>{names.get(n.id) ?? n.id}</td>
|
||||
<td>
|
||||
<Chip>{n.kind}</Chip>
|
||||
</td>
|
||||
<td className="mono">
|
||||
{n.version ? mismatch(n.version) ? <Chip tone="warn">{n.version}</Chip> : n.version : <span className="faint">—</span>}
|
||||
</td>
|
||||
<td className="mono">{n.tag || <span className="faint">—</span>}</td>
|
||||
<td className="mono">{n.commit || <span className="faint">—</span>}</td>
|
||||
<td className="muted">{fmtBuiltAt(n.built_at) || <span className="faint">—</span>}</td>
|
||||
<td className="muted">{[n.os_name, n.arch].filter(Boolean).join(' / ') || <span className="faint">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{nodes.length === 0 && !info.loading ? <Empty>还没有节点上报过版本</Empty> : null}
|
||||
<p className="muted" style={{ marginTop: 12 }}>
|
||||
版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tokens() {
|
||||
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
|
||||
const { run } = useAction()
|
||||
@@ -418,6 +490,7 @@ export function System() {
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: 'settings' as const, label: '设置' },
|
||||
{ value: 'versions' as const, label: '版本' },
|
||||
{ value: 'tokens' as const, label: 'API 令牌' },
|
||||
{ value: 'users' as const, label: '用户' },
|
||||
{ value: 'events' as const, label: '事件' },
|
||||
@@ -427,7 +500,7 @@ export function System() {
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'settings' ? <Settings /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { buildDefines } from '../build-info.mjs'
|
||||
|
||||
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
|
||||
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:29181').replace(/\/+$/, '')
|
||||
return {
|
||||
base: '/admin/',
|
||||
define: buildDefines(mode === 'development'),
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
|
||||
server: {
|
||||
port: 5174,
|
||||
// 5174 常被 Docker Desktop 占用,管理端用 5175;端口被占时直接报错,不悄悄换到别的端口
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
fs: { allow: ['..'] },
|
||||
proxy: {
|
||||
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function buildDefines(dev: boolean): Record<string, string>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* 前端自己的构建信息:版本取自 web/package.json,tag 和提交取自 git(环境变量 MONITOR_TAG / MONITOR_COMMIT 优先,CI 里没有 git 历史时用),
|
||||
* 构建时间是这次构建的时刻。开发服务器里 tag 是「dev」,免得每次刷新都变。
|
||||
*/
|
||||
function git(args) {
|
||||
try {
|
||||
return execSync(`git ${args}`, { cwd: fileURLToPath(new URL('..', import.meta.url)), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefines(dev) {
|
||||
const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
|
||||
return {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
__APP_TAG__: JSON.stringify(dev ? 'dev' : (process.env.MONITOR_TAG || git('describe --tags --always --dirty'))),
|
||||
__APP_COMMIT__: JSON.stringify(dev ? '' : (process.env.MONITOR_COMMIT || git('rev-parse --short=10 HEAD'))),
|
||||
__APP_BUILT_AT__: JSON.stringify(dev ? '' : (process.env.MONITOR_BUILT_AT || new Date().toISOString())),
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -1,7 +1,50 @@
|
||||
// 同时启动展示端(5173)和管理端(5174/admin/);Ctrl+C 一起退出。
|
||||
// 同时启动展示端(5173)和管理端(5175,经展示端的 /admin/ 访问);Ctrl+C 一起退出。
|
||||
// 不经过 shell:直接用 node 跑各自目录下的 vite,输出带前缀,端口被占时直接报错。
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const children = ['display', 'admin'].map((name) => spawn('npm', ['run', 'dev', '-w', name], { stdio: 'inherit', shell: true }))
|
||||
const stop = () => children.forEach((c) => c.kill())
|
||||
const vite = fileURLToPath(new URL('./node_modules/vite/bin/vite.js', import.meta.url))
|
||||
|
||||
// 颜色:和 Vite 自己的输出一致(绿色的 ➜、青色/蓝色的链接);设了 NO_COLOR 就不上色
|
||||
const color = !process.env.NO_COLOR
|
||||
const paint = (code, text) => (color ? `[${code}m${text}[0m` : text)
|
||||
const bold = (t) => paint('1', t)
|
||||
const dim = (t) => paint('2', t)
|
||||
const green = (t) => paint('32', t)
|
||||
const cyan = (t) => paint('36', t)
|
||||
const magenta = (t) => paint('35', t)
|
||||
const link = (url) => paint('36;4', url)
|
||||
|
||||
const apps = [
|
||||
{ name: 'display', tint: cyan, cwd: fileURLToPath(new URL('./display/', import.meta.url)) },
|
||||
{ name: 'admin ', tint: magenta, cwd: fileURLToPath(new URL('./admin/', import.meta.url)) },
|
||||
]
|
||||
|
||||
console.log('')
|
||||
console.log(` ${green('➜')} ${bold('展示端')} ${link('http://localhost:5173/')}`)
|
||||
console.log(` ${green('➜')} ${bold('管理端')} ${link('http://localhost:5173/admin/')} ${dim('(也可以直接开 http://localhost:5175/admin/)')}`)
|
||||
console.log('')
|
||||
|
||||
const children = apps.map(({ name, tint, cwd }) => {
|
||||
const child = spawn(process.execPath, [vite], { cwd, stdio: ['inherit', 'pipe', 'pipe'], env: { ...process.env, ...(color ? { FORCE_COLOR: '1' } : {}) } })
|
||||
const prefix = (stream, out) =>
|
||||
stream.on('data', (chunk) => {
|
||||
for (const line of String(chunk).split(/\r?\n/)) if (line.trim()) out.write(`${tint(`[${name}]`)} ${line}\n`)
|
||||
})
|
||||
prefix(child.stdout, process.stdout)
|
||||
prefix(child.stderr, process.stderr)
|
||||
child.on('exit', (code) => {
|
||||
if (code) {
|
||||
console.error(`${tint(`[${name}]`)} ${paint('31', `退出了(${code}),一起停掉`)}`)
|
||||
stop()
|
||||
process.exitCode = code
|
||||
}
|
||||
})
|
||||
return child
|
||||
})
|
||||
|
||||
function stop() {
|
||||
for (const c of children) c.kill()
|
||||
}
|
||||
process.on('SIGINT', stop)
|
||||
process.on('exit', stop)
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>线路质量</title>
|
||||
<title>MyMonitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BuildFooter } from './BuildFooter'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { SourceButton, ThemeToggle } from '@shared/ui'
|
||||
import { Compare } from './pages/Compare'
|
||||
@@ -9,9 +10,6 @@ export function App() {
|
||||
<>
|
||||
<header className="topbar">
|
||||
<div className="inner">
|
||||
<NavLink to="/" className="brand">
|
||||
线路质量
|
||||
</NavLink>
|
||||
<nav className="topnav">
|
||||
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
总览
|
||||
@@ -21,7 +19,7 @@ export function App() {
|
||||
</NavLink>
|
||||
<a href="/admin/">管理</a>
|
||||
</nav>
|
||||
<SourceButton />
|
||||
<SourceButton variant="status" />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
@@ -31,6 +29,10 @@ export function App() {
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<footer className="site-foot">
|
||||
国家和运营商由 <a href="https://ip.007995.xyz/" target="_blank" rel="noreferrer">ip.007995.xyz</a> 查询
|
||||
<BuildFooter />
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AboutDto, BuildDto } from '@shared/api/types'
|
||||
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
|
||||
/** 「0.1.1 · v0.1.1-5-gabc123 · 2026-10-06 20:30」:版本、tag(和版本一样就不重复写)、构建时间。 */
|
||||
function buildText(b: { version: string; tag?: string | null; built_at?: string | null }): string {
|
||||
const parts = [b.version]
|
||||
if (b.tag && b.tag.replace(/^v/, '') !== b.version) parts.push(b.tag)
|
||||
const at = fmtBuiltAt(b.built_at)
|
||||
if (at) parts.push(at)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面底部的构建信息:后端和前端的版本、tag、构建时间。要管理员在设置里打开「site.show_build」才显示;
|
||||
* 关着的时候(或者后端太旧没有这个接口)什么也不画,前端自己的信息也不单独露出来。
|
||||
*/
|
||||
export function BuildFooter() {
|
||||
const about = useQuery(() => api.get<AboutDto>('/api/public/about'), [], 5 * 60_000)
|
||||
if (!about.data?.show_build || !about.data.backend) return null
|
||||
const backend: BuildDto = about.data.backend
|
||||
return (
|
||||
<div className="build-info">
|
||||
<span>后端 {buildText(backend)}</span>
|
||||
<span>前端 {buildText({ version: frontendBuild.version, tag: frontendBuild.tag, built_at: frontendBuild.builtAt })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useRef, useState, type MouseEvent } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { SeriesDto } from '@shared/api/types'
|
||||
import { fmtMs, fmtPct } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
|
||||
/** 总览卡片可以看的时长,以及每一格的长度(格数约 36~48,太多就挤了)。 */
|
||||
export const OVERVIEW_RANGES = [
|
||||
{ value: '3h', label: '3 小时', hours: 3, step: '5m', stepMin: 5 },
|
||||
{ value: '6h', label: '6 小时', hours: 6, step: '10m', stepMin: 10 },
|
||||
{ value: '12h', label: '12 小时', hours: 12, step: '15m', stepMin: 15 },
|
||||
] as const
|
||||
|
||||
export type OverviewRange = (typeof OVERVIEW_RANGES)[number]['value']
|
||||
|
||||
type Level = 'ok' | 'warn' | 'bad' | 'none'
|
||||
|
||||
const levelOf = (loss: number | null | undefined): Level => (loss === null || loss === undefined ? 'none' : loss <= 0.5 ? 'ok' : loss < 3 ? 'warn' : 'bad')
|
||||
|
||||
const clock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
interface StripProps {
|
||||
/** 左上:什么延迟(UDP 延迟 p50)。 */
|
||||
rttLabel: string
|
||||
rtt: number | null | undefined
|
||||
/** 右上:丢包还是重传。 */
|
||||
badLabel: string
|
||||
bad: number | null | undefined
|
||||
badHint?: string
|
||||
/** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */
|
||||
t: number[]
|
||||
badSeries: (number | null)[]
|
||||
ready: boolean
|
||||
/** 提示里这一格的「丢包」叫什么。 */
|
||||
tipBad: string
|
||||
/** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */
|
||||
cells: number
|
||||
stepText: string
|
||||
hoursText: string
|
||||
/** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */
|
||||
hover: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据)
|
||||
* 尽量轻。
|
||||
*/
|
||||
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) {
|
||||
return (
|
||||
<div className="udp-strip">
|
||||
<div className="udp-head">
|
||||
<span className="udp-title">
|
||||
{rttLabel} <b className="num">{fmtMs(rtt)}</b>
|
||||
</span>
|
||||
<span className="udp-title" title={badHint}>
|
||||
{badLabel} <b className={`udp-rate num ${levelOf(bad)}`}>{fmtPct(bad)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${hoursText}每 ${stepText}的${badLabel}情况`}>
|
||||
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => (
|
||||
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lineId: string
|
||||
range: OverviewRange
|
||||
/** 近 3 小时的汇总(左上、右上的数字)。 */
|
||||
m: Record<string, number | null | undefined>
|
||||
}
|
||||
|
||||
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
|
||||
export function LineStrips({ lineId, range, m }: Props) {
|
||||
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
|
||||
const series = useQuery(
|
||||
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'udp,tcp', metrics: 'loss.total,rtt.p50,tcp.retrans,tcp.rtt.p50' }),
|
||||
[lineId, info.value],
|
||||
30_000,
|
||||
)
|
||||
const s = series.data
|
||||
const t = s?.t ?? []
|
||||
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
||||
// 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
|
||||
if (hover !== null) last.current = hover
|
||||
const shown = hover ?? last.current
|
||||
// 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const onMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const bars = box.current?.querySelector('.bars')
|
||||
if (!s || t.length < 1 || !bars) return
|
||||
const r = bars.getBoundingClientRect()
|
||||
setHover(Math.min(t.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * t.length))))
|
||||
}
|
||||
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
|
||||
const at = shown !== null && t[shown] !== undefined ? shown : null
|
||||
return (
|
||||
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||
<Strip
|
||||
{...common}
|
||||
rttLabel="UDP 延迟 p50"
|
||||
rtt={m['rtt.p50']}
|
||||
badLabel="丢包"
|
||||
bad={m['loss.total']}
|
||||
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
|
||||
badSeries={col('loss.total')}
|
||||
tipBad="丢包"
|
||||
/>
|
||||
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} tipBad="重传" />
|
||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||
{at !== null ? (
|
||||
<>
|
||||
<b className="pop-time">
|
||||
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
|
||||
</b>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">UDP</span>
|
||||
<span>
|
||||
丢包 <em className={levelOf(col('loss.total')[at])}>{fmtPct(col('loss.total')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
延迟 p50 <em>{fmtMs(col('rtt.p50')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">TCP</span>
|
||||
<span>
|
||||
重传 <em className={levelOf(col('tcp.retrans')[at])}>{fmtPct(col('tcp.retrans')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'
|
||||
import { LoadingBar } from '@shared/ui'
|
||||
import { fmtSpan } from './ranges'
|
||||
|
||||
const MIN = 60_000
|
||||
/** 选区最短 5 分钟。 */
|
||||
const MIN_LEN = 5
|
||||
const EDGE_PX = 9
|
||||
|
||||
interface Props {
|
||||
/** 时间轴的起点(毫秒)。 */
|
||||
domainStart: number
|
||||
/** 时间轴的长度(分钟):1440 或 2880(连续 48 小时,选区可以跨零点)。 */
|
||||
domainMin: number
|
||||
t: number[]
|
||||
/** 丢包(缩略图画它)。 */
|
||||
values: (number | null)[]
|
||||
fromMs: number
|
||||
toMs: number
|
||||
onSelect: (fromMs: number, toMs: number) => void
|
||||
/** 缩略图的数据正在加载。 */
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
type Mode = 'l' | 'r' | 'move' | 'new'
|
||||
interface Drag {
|
||||
mode: Mode
|
||||
anchor: number
|
||||
from: number
|
||||
to: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
const hourOf = (ms: number) => new Date(ms).getHours()
|
||||
const dateOf = (ms: number) => `${new Date(ms).getMonth() + 1}/${new Date(ms).getDate()}`
|
||||
const clockOf = (ms: number) => `${String(new Date(ms).getHours()).padStart(2, '0')}:${String(new Date(ms).getMinutes()).padStart(2, '0')}`
|
||||
|
||||
/**
|
||||
* 连续 48 小时的缩略图(丢包),上面有一块可拖动的选区:
|
||||
* 拖空白处新建,拖两边调长短,拖中间整体平移;选区可以跨过零点(晚高峰常常是 20:00 到凌晨 02:00)。
|
||||
* 点一下空白处或者刻度下面的钟点:离那里近的端点贴过去,另一个端点不动;鼠标移上去会标出这个位置的时间。
|
||||
* 拖动过程中只在这里本地更新,松手才通知外面(外面再去取数),所以不会闪。
|
||||
*/
|
||||
export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSelect, loading = false }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<Drag | null>(null)
|
||||
const latest = useRef<[number, number] | null>(null)
|
||||
const [live, setLive] = useState<[number, number] | null>(null)
|
||||
const [held, setHeld] = useState<[number, number] | null>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
|
||||
const propFrom = Math.round((fromMs - domainStart) / MIN)
|
||||
const propTo = Math.round((toMs - domainStart) / MIN)
|
||||
// 松手之后到外面的状态更新完成之前,选区停在松手的位置,不跳回去
|
||||
useEffect(() => {
|
||||
if (held && propFrom === held[0] && propTo === held[1]) setHeld(null)
|
||||
}, [propFrom, propTo, held])
|
||||
useEffect(() => {
|
||||
if (!held) return
|
||||
const id = window.setTimeout(() => setHeld(null), 4000)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [held])
|
||||
|
||||
const shown = live ?? held ?? [propFrom, propTo]
|
||||
const from = Math.max(0, shown[0])
|
||||
const to = Math.min(domainMin, shown[1])
|
||||
|
||||
const path = useMemo(() => {
|
||||
let max = 5
|
||||
for (const v of values) if (v !== null && v !== undefined && v > max) max = v
|
||||
const points: { x: number; v: number }[] = []
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
const v = values[i]
|
||||
const x = (t[i] - domainStart) / (domainMin * MIN)
|
||||
if (v !== null && v !== undefined && x >= 0 && x <= 1) points.push({ x, v })
|
||||
}
|
||||
if (points.length < 2) return ''
|
||||
const first = points[0].x * 1000
|
||||
const last = points[points.length - 1].x * 1000
|
||||
return `M${first.toFixed(1)},60 ` + points.map((p) => `L${(p.x * 1000).toFixed(1)},${(58 - (Math.min(p.v, max) / max) * 54).toFixed(1)}`).join(' ') + ` L${last.toFixed(1)},60 Z`
|
||||
}, [t, values, domainStart, domainMin])
|
||||
|
||||
// 有数据的时间段:其余(还没有数据的更早时间、中间的缺口、还没到的未来)画成灰色斜纹
|
||||
const covered = useMemo(() => {
|
||||
const step = t.length > 1 ? t[1] - t[0] : 2 * MIN
|
||||
const segments: { x: number; w: number }[] = []
|
||||
let start = -1
|
||||
let last = -1
|
||||
const flush = () => {
|
||||
if (start >= 0) segments.push({ x: ((start - domainStart) / (domainMin * MIN)) * 1000, w: Math.max(2, ((last + step - start) / (domainMin * MIN)) * 1000) })
|
||||
start = -1
|
||||
}
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
if (values[i] === null || values[i] === undefined) continue
|
||||
if (start >= 0 && t[i] - last > step * 2.5) flush()
|
||||
if (start < 0) start = t[i]
|
||||
last = t[i]
|
||||
}
|
||||
flush()
|
||||
return segments
|
||||
}, [t, values, domainStart, domainMin])
|
||||
|
||||
// 刻度:每小时一个小短线;24 小时刻度每 2 小时、48 小时刻度每 3 小时标一个钟点;零点标日期
|
||||
const ticks = useMemo(() => {
|
||||
const labelEvery = domainMin <= 1440 ? 2 : 3
|
||||
const list: { hour: number; frac: number; label: string | null; midnight: boolean }[] = []
|
||||
for (let h = 0; h <= domainMin / 60; h += 1) {
|
||||
const ms = domainStart + h * 3_600_000
|
||||
const hour = hourOf(ms)
|
||||
const labelled = h % labelEvery === 0 || hour === 0
|
||||
list.push({ hour: h, frac: h / (domainMin / 60), label: !labelled ? null : hour === 0 ? dateOf(ms) : `${hour}时`, midnight: hour === 0 })
|
||||
}
|
||||
return list
|
||||
}, [domainStart, domainMin])
|
||||
|
||||
const minuteAt = (e: PointerEvent) => {
|
||||
const rect = box.current!.getBoundingClientRect()
|
||||
return Math.min(domainMin, Math.max(0, ((e.clientX - rect.left) / rect.width) * domainMin))
|
||||
}
|
||||
const clamp = (a: number, b: number): [number, number] => {
|
||||
let lo = Math.round(Math.min(a, b))
|
||||
let hi = Math.round(Math.max(a, b))
|
||||
if (hi - lo < MIN_LEN) hi = Math.min(domainMin, lo + MIN_LEN)
|
||||
if (hi - lo < MIN_LEN) lo = hi - MIN_LEN
|
||||
return [Math.max(0, lo), Math.min(domainMin, hi)]
|
||||
}
|
||||
|
||||
const down = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return
|
||||
const rect = box.current!.getBoundingClientRect()
|
||||
const pxPerMin = rect.width / domainMin
|
||||
const at = minuteAt(e)
|
||||
let mode: Mode = 'new'
|
||||
if (Math.abs(at - from) * pxPerMin <= EDGE_PX) mode = 'l'
|
||||
else if (Math.abs(at - to) * pxPerMin <= EDGE_PX) mode = 'r'
|
||||
else if (at > from && at < to) mode = 'move'
|
||||
drag.current = { mode, anchor: at, from, to, moved: false }
|
||||
latest.current = null
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
const move = (e: PointerEvent) => {
|
||||
const d = drag.current
|
||||
if (!d) return
|
||||
const at = minuteAt(e)
|
||||
setHover(null)
|
||||
if (!d.moved && Math.abs(at - d.anchor) * (box.current!.getBoundingClientRect().width / domainMin) < 3) return
|
||||
d.moved = true
|
||||
let next: [number, number]
|
||||
if (d.mode === 'new') next = clamp(d.anchor, at)
|
||||
else if (d.mode === 'l') next = clamp(at, d.to)
|
||||
else if (d.mode === 'r') next = clamp(d.from, at)
|
||||
else {
|
||||
const len = d.to - d.from
|
||||
const start = Math.min(domainMin - len, Math.max(0, d.from + (at - d.anchor)))
|
||||
next = [Math.round(start), Math.round(start + len)]
|
||||
}
|
||||
latest.current = next
|
||||
setLive(next)
|
||||
}
|
||||
const up = () => {
|
||||
const d = drag.current
|
||||
const result = latest.current
|
||||
drag.current = null
|
||||
latest.current = null
|
||||
setLive(null)
|
||||
if (d?.moved && result && (result[0] !== d.from || result[1] !== d.to)) {
|
||||
setHeld(result)
|
||||
onSelect(domainStart + result[0] * MIN, domainStart + result[1] * MIN)
|
||||
} else if (d && !d.moved && (d.mode === 'new' || d.mode === 'move')) {
|
||||
// 只是点了一下:离点的位置近的那个端点贴过去,另一个端点不动
|
||||
snapTo(d.anchor, d.from, d.to)
|
||||
}
|
||||
}
|
||||
/** 哪个端点离 `minute` 近,就把哪个端点移到那里(点在选区外面也一样),另一端不动。 */
|
||||
const predict = (minute: number, a = from, b = to): { next: [number, number]; side: 'l' | 'r' } => {
|
||||
const at = Math.round(minute)
|
||||
return Math.abs(at - a) <= Math.abs(at - b) ? { next: clamp(at, b), side: 'l' } : { next: clamp(a, at), side: 'r' }
|
||||
}
|
||||
const snapTo = (minute: number, a = from, b = to) => {
|
||||
const { next } = predict(minute, a, b)
|
||||
if (next[0] === a && next[1] === b) return
|
||||
setHeld(next)
|
||||
onSelect(domainStart + next[0] * MIN, domainStart + next[1] * MIN)
|
||||
}
|
||||
// 点下去会动的是哪个端点:从那个端点到目标位置之间染色(扩大是一种颜色,收缩是另一种),那个端点也变色
|
||||
const target = hover !== null && !live && !drag.current ? predict(hover) : null
|
||||
const sweep = target
|
||||
? target.side === 'l'
|
||||
? { lo: Math.min(from, target.next[0]), hi: Math.max(from, target.next[0]), grow: target.next[0] < from, changed: target.next[0] !== from }
|
||||
: { lo: Math.min(to, target.next[1]), hi: Math.max(to, target.next[1]), grow: target.next[1] > to, changed: target.next[1] !== to }
|
||||
: null
|
||||
const jumpTo = (tickHour: number) => snapTo(tickHour * 60)
|
||||
const hoverMove = (e: PointerEvent) => {
|
||||
if (drag.current) return
|
||||
setHover(minuteAt(e))
|
||||
}
|
||||
|
||||
const length = to - from
|
||||
|
||||
return (
|
||||
<div className="minimap">
|
||||
<div ref={box} className="track" onPointerDown={down} onPointerMove={(e) => { move(e); hoverMove(e) }} onPointerLeave={() => setHover(null)} onPointerUp={up} onPointerCancel={up} role="slider" aria-label="拖动选择时间段" aria-valuemin={0} aria-valuemax={domainMin} aria-valuenow={from} tabIndex={0}>
|
||||
<LoadingBar active={loading} />
|
||||
<svg viewBox="0 0 1000 60" preserveAspectRatio="none" aria-hidden="true">
|
||||
{covered.map((c) => (
|
||||
<rect key={c.x} className="has" x={c.x} y="0" width={c.w} height="60" />
|
||||
))}
|
||||
<path d={path} />
|
||||
</svg>
|
||||
{ticks
|
||||
.filter((x) => x.midnight)
|
||||
.map((x) => (
|
||||
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
|
||||
))}
|
||||
{sweep?.changed ? <div className={`sweep ${sweep.grow ? 'grow' : 'cut'}`} style={{ left: `${(sweep.lo / domainMin) * 100}%`, width: `${((sweep.hi - sweep.lo) / domainMin) * 100}%` }} /> : null}
|
||||
<div className="sel" style={{ left: `${(from / domainMin) * 100}%`, width: `${(length / domainMin) * 100}%` }}>
|
||||
<i className={`h l ${sweep?.changed && target?.side === 'l' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
|
||||
<i className={`h r ${sweep?.changed && target?.side === 'r' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
|
||||
</div>
|
||||
{hover !== null && !live ? (
|
||||
<>
|
||||
<div className="guide" style={{ left: `${(hover / domainMin) * 100}%` }} />
|
||||
<div className="guide-label num" style={{ left: `${Math.min(88, Math.max(0, (hover / domainMin) * 100 - 6))}%` }}>
|
||||
{dateOf(domainStart + hover * MIN)} {clockOf(domainStart + hover * MIN)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="ticks">
|
||||
{ticks.map((x, i) => (
|
||||
<button
|
||||
key={x.hour}
|
||||
type="button"
|
||||
className={`tick ${x.label ? '' : 'minor'} ${x.midnight ? 'date' : ''}`}
|
||||
style={{ left: `${x.frac * 100}%`, transform: i === 0 ? 'none' : i === ticks.length - 1 ? 'translateX(-100%)' : undefined }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerMove={(e) => {
|
||||
// 停在刻度上:按这个钟点预览(不是鼠标所在的像素位置)
|
||||
e.stopPropagation()
|
||||
if (x.hour * 60 < domainMin) setHover(x.hour * 60)
|
||||
}}
|
||||
onClick={() => jumpTo(x.hour)}
|
||||
title={`离 ${clockOf(domainStart + x.hour * 3_600_000)} 近的端点移到这里`}
|
||||
tabIndex={-1}
|
||||
disabled={x.hour * 60 >= domainMin}
|
||||
>
|
||||
{x.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="readout num">
|
||||
{fmtSpan(domainStart + from * MIN, domainStart + to * MIN)}
|
||||
<span className="faint"> · {length >= 60 ? `${Math.floor(length / 60)} 小时${length % 60 ? ` ${length % 60} 分` : ''}` : `${length} 分钟`}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
+773
-6
@@ -239,6 +239,7 @@
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -276,14 +277,28 @@
|
||||
|
||||
.line-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.line-link > .muted {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-link > .scores {
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.line-link:hover {
|
||||
text-decoration: none;
|
||||
background: var(--app-card-hover);
|
||||
@@ -398,15 +413,42 @@
|
||||
background: var(--app-ok);
|
||||
}
|
||||
|
||||
.line-link > * {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.range-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.day-pick {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.day-pick:hover {
|
||||
background: var(--app-card-hover);
|
||||
}
|
||||
|
||||
.day-pick input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */
|
||||
.winners {
|
||||
margin-top: 24px;
|
||||
@@ -460,3 +502,728 @@
|
||||
.cell svg {
|
||||
color: var(--app-ok);
|
||||
}
|
||||
|
||||
/* ── 线路详情:指标平铺成一格一格 ── */
|
||||
.tiles {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.tiles > .stat,
|
||||
.tiles > .tile {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--app-card);
|
||||
}
|
||||
|
||||
.tiles > .tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.tiles > .tile .k {
|
||||
font-size: 12px;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.tiles .stat .v {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.tiles > .tile .scores {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* ── 一天的缩略图和可拖动的选区 ── */
|
||||
.minimap {
|
||||
--pick: var(--chart-1);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.minimap .track {
|
||||
position: relative;
|
||||
height: 60px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: var(--r-control);
|
||||
/* 没有数据的地方:灰色斜纹 */
|
||||
background: repeating-linear-gradient(135deg, var(--app-card-inset) 0 5px, var(--app-card-hover) 5px 10px);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.minimap svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--r-control);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.minimap svg path {
|
||||
fill: var(--app-bad);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.minimap svg rect.has {
|
||||
fill: var(--app-card);
|
||||
}
|
||||
|
||||
.minimap .sel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border: 2px solid var(--pick);
|
||||
border-radius: var(--r-control);
|
||||
background: color-mix(in srgb, var(--pick) 16%, transparent);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.minimap .sel .h {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--pick);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.minimap .sel .h.l {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.minimap .sel .h.r {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.minimap .ticks {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.minimap .tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
min-width: 16px;
|
||||
height: 18px;
|
||||
padding: 7px 2px 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--app-ink-faint);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* 刻度线:每小时一根,标了钟点的更长 */
|
||||
.minimap .tick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--app-ink-faint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.minimap .tick.minor::before {
|
||||
height: 3px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.minimap .tick:not(.minor)::before {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.minimap .tick:hover:not(:disabled) {
|
||||
background: var(--app-card-hover);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.minimap .tick:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.minimap .tick.date {
|
||||
font-weight: 600;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.minimap .guide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--app-ink-muted);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap .guide-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--app-ink);
|
||||
color: var(--app-card);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap .readout {
|
||||
font-size: 12px;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.day-pick {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.minimap .midnight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--app-ink-faint);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */
|
||||
.chart-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-section h2 {
|
||||
margin: 8px 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.chart-section .chart-card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.minimap .loading-bar {
|
||||
z-index: 2;
|
||||
border-radius: var(--r-control) var(--r-control) 0 0;
|
||||
}
|
||||
|
||||
/* 点下去会动的端点:它到目标位置之间染色(扩大 = 强调色,收缩 = 灰色),端点本身也变色 */
|
||||
.minimap .sweep {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.minimap .sweep.grow {
|
||||
background: color-mix(in srgb, var(--chart-2) 32%, transparent);
|
||||
}
|
||||
|
||||
.minimap .sweep.cut {
|
||||
background: color-mix(in srgb, var(--app-ink) 18%, transparent);
|
||||
}
|
||||
|
||||
.minimap .sel .h {
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.minimap .sel .h.will.grow {
|
||||
background: var(--chart-2);
|
||||
}
|
||||
|
||||
.minimap .sel .h.will.cut {
|
||||
background: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
/* 顶栏里的连接状态:紧凑的一条,放在导航和主题切换之间 */
|
||||
.topbar .src-status {
|
||||
width: auto;
|
||||
max-width: 220px;
|
||||
border-radius: var(--r-pill, 999px);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar .src-status .st {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar .src-status {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.line-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lines-caption {
|
||||
padding: 0 4px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */
|
||||
.line-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.line-top .faint {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.udp-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.udp-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.udp-title {
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.udp-title b {
|
||||
margin-left: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.ok {
|
||||
color: var(--app-ok);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.warn {
|
||||
color: var(--app-warn);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.bad {
|
||||
color: var(--app-bad);
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--app-idle);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.bar.ok {
|
||||
background: var(--app-ok);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.bar.warn {
|
||||
background: var(--app-warn);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bar.bad {
|
||||
background: var(--app-bad);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* UDP 和 TCP 两段之间留一点空 */
|
||||
.strips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 悬停的那一格:小格描边、走势图上一条竖线和一个点,再弹一个小提示 */
|
||||
.bar.on {
|
||||
outline: 2px solid var(--app-ink);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.strips {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 提示弹在卡片右侧:平时收在卡片边上(透明、偏左),悬停时滑出来,移开时缩回去 */
|
||||
.pop {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: calc(100% + 40px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 168px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-ink);
|
||||
color: var(--app-card);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateX(-14px) scale(0.96);
|
||||
transform-origin: left center;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--app-shadow), transparent 68%);
|
||||
}
|
||||
|
||||
.pop.open {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 指向卡片的小三角 */
|
||||
.pop::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: -5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--app-ink);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pop-time {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.pop-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.pop-name {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pop em {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.pop em.warn {
|
||||
color: var(--app-warn-container);
|
||||
}
|
||||
|
||||
.pop em.bad {
|
||||
color: var(--app-bad-container);
|
||||
}
|
||||
|
||||
/* 右边放不下(窄屏、卡片贴着右边)时,提示改成盖在卡片上方 */
|
||||
@media (max-width: 900px) {
|
||||
.pop {
|
||||
left: auto;
|
||||
right: 0;
|
||||
top: -8px;
|
||||
transform: translateY(8px) scale(0.96);
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
|
||||
.pop::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.site-foot {
|
||||
max-width: 900px;
|
||||
margin: 48px auto 24px;
|
||||
padding: 0 16px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.site-foot a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Agent 卡片:国旗开头,名字下面是「运营商 / 系统」两行(左边名字右边内容,和下面的流量、网速一个风格) */
|
||||
/* 亚克力:整张卡片半透明 + 背景模糊,细亮边,大一点的投影;标题栏是稍深的一条,底下垫放大模糊的国旗 */
|
||||
/* 背景图固定在最底下,自己模糊一点(边缘放大一点,免得模糊后露出白边);上面盖一层页面底色,亮色盖得浅一点,暗色盖得深 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -24px;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent), color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent)),
|
||||
url('./assets/bg.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(var(--bg-blur));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-veil: 50%;
|
||||
--bg-blur: 10px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg-veil: 82%;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
background: color-mix(in srgb, var(--app-card) 76%, transparent);
|
||||
backdrop-filter: blur(28px) saturate(1.6);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
|
||||
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
|
||||
}
|
||||
|
||||
.agent-head {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin: -20px -20px 0;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--r-card) var(--r-card) 0 0;
|
||||
background: color-mix(in srgb, var(--app-ink) 5%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--app-ink) 8%, transparent);
|
||||
}
|
||||
|
||||
.agent-head .flag {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px var(--app-outline-soft);
|
||||
}
|
||||
|
||||
.agent-head .flag.ph {
|
||||
display: inline-block;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fact .k {
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.fact .v {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.fact .v .isp {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.fact .v .isp rect {
|
||||
fill: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.fact .v .isp text {
|
||||
fill: var(--isp-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fact .v .isp-broadnet rect {
|
||||
fill: var(--isp-broadnet);
|
||||
}
|
||||
|
||||
/* 线路行:国旗在 Probe 名字前面,运营商图标在后面 */
|
||||
.line-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.line-name .flag {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 13.5px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px var(--app-outline-soft);
|
||||
}
|
||||
|
||||
.line-top .isp {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.line-top .isp rect {
|
||||
fill: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.line-top .isp text {
|
||||
fill: var(--isp-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.line-top .isp-broadnet rect {
|
||||
fill: var(--isp-broadnet);
|
||||
}
|
||||
|
||||
/* 亚克力卡片里的线路块:不用实色,只叠一层淡淡的墨色和细描边,透出卡片本身 */
|
||||
.agent-card .line-link {
|
||||
background: color-mix(in srgb, var(--app-ink) 4%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 7%, transparent);
|
||||
}
|
||||
|
||||
.agent-card .line-link:hover {
|
||||
background: color-mix(in srgb, var(--app-ink) 7%, transparent);
|
||||
}
|
||||
|
||||
|
||||
/* 线路详情、对比等页面的卡片和指标块:同样的亚克力,一层压一层 */
|
||||
.card:not(.agent-card),
|
||||
.tiles > .stat,
|
||||
.tiles > .tile {
|
||||
background: color-mix(in srgb, var(--app-card) 76%, transparent);
|
||||
backdrop-filter: blur(28px) saturate(1.6);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
|
||||
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
|
||||
}
|
||||
|
||||
/* 小的指标块浮得低一点:阴影收小 */
|
||||
.tiles > .stat {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 8%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
|
||||
0 6px 14px -8px color-mix(in srgb, var(--app-shadow) 40%, transparent);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: color-mix(in srgb, var(--app-card) 62%, transparent);
|
||||
backdrop-filter: blur(20px) saturate(1.5);
|
||||
border-bottom-color: color-mix(in srgb, var(--app-ink) 7%, transparent);
|
||||
box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--app-shadow) 28%, transparent);
|
||||
}
|
||||
|
||||
/* 时间轴:同样的亚克力。有数据的段是半透明的亮底,没数据的段是淡淡的斜纹,整条有细描边和投影 */
|
||||
.minimap .track {
|
||||
background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--app-card) 55%, var(--app-idle)) 0 5px, color-mix(in srgb, var(--app-card) 70%, var(--app-idle)) 5px 10px);
|
||||
backdrop-filter: blur(20px) saturate(1.6);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
|
||||
0 10px 24px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
|
||||
}
|
||||
|
||||
.minimap svg rect.has {
|
||||
fill: color-mix(in srgb, var(--app-card) 80%, transparent);
|
||||
}
|
||||
|
||||
/* 直接落在背景图上的字(页面标题、分组名、说明):加一圈和页面底色一样的柔光,免得被图案吃掉 */
|
||||
body {
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--app-bg) 90%, transparent), 0 0 2px color-mix(in srgb, var(--app-bg) 70%, transparent);
|
||||
}
|
||||
|
||||
.card,
|
||||
.pop,
|
||||
.tiles > .stat,
|
||||
.tiles > .tile,
|
||||
.topbar,
|
||||
button,
|
||||
input {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* 时间轴整条做成圆角:轨道、里面的图、选区用同一个半径 */
|
||||
.minimap .track,
|
||||
.minimap svg,
|
||||
.minimap .sel {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* 版本、tag:等宽的小字 */
|
||||
.fact .v.mono-tag,
|
||||
.probe-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;
|
||||
justify-content: center;
|
||||
gap: 4px 16px;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import '@shared/theme/base.css'
|
||||
import { configureSource } from '@shared/api/source'
|
||||
import { applyStoredTheme } from '@shared/hooks'
|
||||
import { ToastProvider } from '@shared/ui'
|
||||
import { ErrorBoundary, ToastProvider } from '@shared/ui'
|
||||
import { App } from './App'
|
||||
import './display.css'
|
||||
|
||||
@@ -14,9 +14,11 @@ applyStoredTheme()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -20,9 +20,9 @@ interface MetricInfo {
|
||||
const METRICS: MetricInfo[] = [
|
||||
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
|
||||
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
|
||||
{ id: 'loss.total', label: '丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: '下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
|
||||
|
||||
+276
-134
@@ -2,14 +2,19 @@ import { ChevronLeft } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { LineDto, SeriesDto, TestDto } from '@shared/api/types'
|
||||
import type { LineDto, TestDto } from '@shared/api/types'
|
||||
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
||||
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { fmtBytes, useHosts } from '../live'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, Spinner, Stat } from '@shared/ui'
|
||||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
import { availablePresets, defaultPreset, monthsWithData, PRESETS, RangePicker, resolveRange, SLICES, type PresetValue, type RangeState, type SliceValue } from '../ranges'
|
||||
import { Minimap } from '../Minimap'
|
||||
import { readSpec, usePeriod, usePeriodSeries, writeSpec } from '../periods'
|
||||
import { dayBounds, dayKey, RangePicker, WINDOWS, type WindowValue } from '../ranges'
|
||||
|
||||
const MIN = 60_000
|
||||
|
||||
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
|
||||
if (value === null || value === undefined) return ''
|
||||
@@ -23,70 +28,216 @@ interface HostSeries {
|
||||
values: Record<string, (number | null)[]>
|
||||
}
|
||||
|
||||
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
|
||||
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string }[] = [
|
||||
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
|
||||
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
|
||||
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' },
|
||||
{ label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs },
|
||||
{ label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs },
|
||||
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
]
|
||||
|
||||
interface ChartDef {
|
||||
id: string
|
||||
title: string
|
||||
unit: string
|
||||
zero?: boolean
|
||||
fill?: boolean
|
||||
series: { key: string; label: string; color: string; dash?: number[] }[]
|
||||
group: 'udp' | 'tcp' | 'score'
|
||||
/** 占满一行。 */
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 'udp', title: 'UDP(往返探测)' },
|
||||
{ id: 'tcp', title: 'TCP' },
|
||||
{ id: 'score', title: '评分' },
|
||||
] as const
|
||||
|
||||
const CHARTS: ChartDef[] = [
|
||||
{ id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
|
||||
{ id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||||
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
|
||||
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
||||
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
|
||||
]
|
||||
|
||||
/** EWMA 平滑的力度:系数越小越平滑(s = α·x + (1−α)·s)。 */
|
||||
const EWMA = [
|
||||
{ value: 'off', label: '关', alpha: 0 },
|
||||
{ value: 'low', label: '轻', alpha: 0.3 },
|
||||
{ value: 'mid', label: '中', alpha: 0.1 },
|
||||
{ value: 'high', label: '重', alpha: 0.03 },
|
||||
] as const
|
||||
type EwmaValue = (typeof EWMA)[number]['value']
|
||||
|
||||
/** 指数加权移动平均;缺口(没有数据)处断开,缺口后重新开始,不会把缺口两边连在一起。 */
|
||||
function ewma(values: (number | null)[], alpha: number): (number | null)[] {
|
||||
let state: number | null = null
|
||||
return values.map((v) => {
|
||||
if (v === null || v === undefined) {
|
||||
state = null
|
||||
return null
|
||||
}
|
||||
state = state === null ? v : alpha * v + (1 - alpha) * state
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
|
||||
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
|
||||
const history = useQuery(
|
||||
() => (agentId && range ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)),
|
||||
[agentId, range],
|
||||
30_000,
|
||||
)
|
||||
const { hosts } = useHosts()
|
||||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
||||
const data = history.data
|
||||
const charts = useMemo(
|
||||
() =>
|
||||
data
|
||||
? {
|
||||
usage: [
|
||||
{ label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' },
|
||||
{ label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' },
|
||||
{ label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' },
|
||||
],
|
||||
speed: [
|
||||
{ label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' },
|
||||
{ label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
[data],
|
||||
)
|
||||
if (!host && !data) return null
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">主机状态</div>
|
||||
{host ? (
|
||||
<div className="grid stats">
|
||||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
|
||||
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||||
</div>
|
||||
) : null}
|
||||
{data && charts ? (
|
||||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
||||
<TimeChart t={data.t} unit="%" series={charts.usage} />
|
||||
<TimeChart t={data.t} unit=" B/s" series={charts.speed} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Line() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
|
||||
const infos = useNodeInfos()
|
||||
const ready = Boolean(line.data)
|
||||
const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null
|
||||
const nowMs = useMemo(() => Date.now(), [line.data])
|
||||
const spanMs = sinceMs === null ? null : nowMs - sinceMs
|
||||
const todayStart = dayBounds(nowMs).start
|
||||
|
||||
// 时间范围:只提供有数据的档位;URL 里的值如果超出了现有的数据就退回到默认
|
||||
const wanted = params.get('range')
|
||||
const available = availablePresets(spanMs)
|
||||
const preset: PresetValue = PRESETS.some((x) => x.value === wanted) && available.some((x) => x.value === wanted) ? (wanted as PresetValue) : defaultPreset(spanMs)
|
||||
const sliceParam = params.get('slice')
|
||||
const state: RangeState = {
|
||||
preset,
|
||||
month: params.get('month'),
|
||||
slice: SLICES.some((x) => x.value === sliceParam) ? (sliceParam as SliceValue) : 'all',
|
||||
page: Math.max(0, Number(params.get('page') ?? 0) || 0),
|
||||
}
|
||||
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
|
||||
const resolved = resolveRange(state, months, nowMs)
|
||||
const rangeKey = JSON.stringify(resolved.query)
|
||||
const setRange = (next: RangeState) => {
|
||||
const q = new URLSearchParams()
|
||||
if (next.month) {
|
||||
q.set('month', next.month)
|
||||
if (next.slice !== 'all') q.set('slice', next.slice)
|
||||
if (next.page > 0) q.set('page', String(next.page))
|
||||
} else {
|
||||
q.set('range', next.preset)
|
||||
}
|
||||
const spec = readSpec(params)
|
||||
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
|
||||
const series = usePeriodSeries(id, ready, period)
|
||||
|
||||
const update = (fn: (q: URLSearchParams) => void) => {
|
||||
const q = new URLSearchParams(params)
|
||||
fn(q)
|
||||
setParams(q, { replace: true })
|
||||
}
|
||||
const dayParam = (dayStart: number) => (dayStart === todayStart ? null : dayKey(dayStart))
|
||||
const changeDay = (next: { dayStart: number; window: WindowValue | null }) =>
|
||||
update((q) => writeSpec(q, { ...readSpec(q), day: dayParam(next.dayStart), range: next.window, at: null }))
|
||||
const selectRange = (fromMs: number, toMs: number) =>
|
||||
update((q) => {
|
||||
const a = Math.round((fromMs - period.dayStart) / MIN)
|
||||
const b = Math.round((toMs - period.dayStart) / MIN)
|
||||
writeSpec(q, { ...readSpec(q), day: dayParam(period.dayStart), range: null, at: `${a}_${b}` })
|
||||
})
|
||||
/** 点了一档时长。看的是最新的(今天、选区到现在)就继续看「最近一段」;否则保持选区的起点,往后取这么长。 */
|
||||
const pickLength = (w: WindowValue) =>
|
||||
update((q) => {
|
||||
const base = { ...readSpec(q), day: dayParam(period.dayStart) }
|
||||
const info = WINDOWS.find((x) => x.value === w)!
|
||||
if (w === 'day' || (period.isToday && (period.live || period.toMs >= nowMs - 2 * MIN))) {
|
||||
writeSpec(q, { ...base, range: w, at: null })
|
||||
return
|
||||
}
|
||||
const start = Math.min(period.fromMs, nowMs - info.ms)
|
||||
const a = Math.round((start - period.dayStart) / MIN)
|
||||
writeSpec(q, { ...base, range: null, at: `${a}_${a + info.ms / MIN}` })
|
||||
})
|
||||
const setScale = (scale: 24 | 48) =>
|
||||
update((q) => {
|
||||
const current = readSpec(q)
|
||||
// 换回 24 小时:跨零点的选区放不下了,回到默认
|
||||
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
|
||||
})
|
||||
|
||||
const series = useQuery(
|
||||
() => (line.data ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(id)}/series`, { ...resolved.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
|
||||
[id, rangeKey, Boolean(line.data)],
|
||||
state.month ? undefined : 10_000,
|
||||
)
|
||||
const agentId = line.data?.agent_id
|
||||
const hostHistory = useQuery(
|
||||
() => (agentId && !state.month ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range: resolved.hostRange }) : Promise.resolve(undefined)),
|
||||
[agentId, resolved.hostRange, Boolean(state.month)],
|
||||
30_000,
|
||||
)
|
||||
const { hosts } = useHosts()
|
||||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
||||
const lastTestId = line.data?.last_test?.id
|
||||
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
|
||||
|
||||
const smooth = EWMA.find((x) => x.value === params.get('ewma')) ?? EWMA[0]
|
||||
const s = series.data
|
||||
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
||||
const m = s?.summary.metrics ?? {}
|
||||
const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s])
|
||||
const t = s?.t ?? []
|
||||
|
||||
const chart = (title: string, unit: string, list: ChartSeries[], zero = true) =>
|
||||
s && list.some((x) => x.values.some((v) => v !== null)) ? (
|
||||
<div className="card chart-card">
|
||||
<h3>{title}</h3>
|
||||
<TimeChart t={t} series={list} unit={unit} gaps={gaps} zero={zero} />
|
||||
// 图表只在数据变化时更新;别的状态(加载中、实时推送)变化不会让它们闪
|
||||
const charts = useMemo(
|
||||
() =>
|
||||
s
|
||||
? GROUPS.map((group) => {
|
||||
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
|
||||
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
|
||||
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
|
||||
const list: ChartSeries[] =
|
||||
smooth.alpha > 0
|
||||
? [
|
||||
...raw.map(({ x, values }) => ({ label: `${x.label}(原始)`, values, color: x.color, faint: true })),
|
||||
...raw.map(({ x, values }) => ({ label: x.label, values: ewma(values, smooth.alpha), color: x.color, dash: x.dash, fill: def.fill })),
|
||||
]
|
||||
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
|
||||
if (!list.some((x) => x.values.some((v) => v !== null))) return null
|
||||
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={def.wide ? 260 : 220} /> }
|
||||
})
|
||||
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
|
||||
}).filter((g) => g.cards.length > 0)
|
||||
: [],
|
||||
[s, gaps, smooth.alpha],
|
||||
)
|
||||
const loadingNow = !ready || series.busy
|
||||
const chartSections = charts.map(({ group, cards }) => (
|
||||
<section className="chart-section" key={group.id}>
|
||||
<h2>{group.title}</h2>
|
||||
<div className="grid cols-2">
|
||||
{cards.map(({ def, node }) => (
|
||||
<div className={`card chart-card has-loading ${def.wide ? 'wide' : ''}`} key={def.id}>
|
||||
<LoadingBar active={loadingNow} />
|
||||
<h3>{def.title}</h3>
|
||||
{node}
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
|
||||
const result = test.data?.result
|
||||
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
||||
@@ -109,111 +260,102 @@ export function Line() {
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,Probe 未连接') : 'Agent 离线'}</span>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="faint">Agent</span>
|
||||
<NodeTag info={infos.get(line.data.agent_id)} />
|
||||
</span>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="faint">Probe</span>
|
||||
<NodeTag info={infos.get(line.data.probe_id)} />
|
||||
</span>
|
||||
{line.data.labels.map((x) => (
|
||||
<Chip key={x}>{x}</Chip>
|
||||
))}
|
||||
{series.loading && !s ? <Spinner /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="toolbar">
|
||||
<RangePicker state={state} onChange={setRange} sinceMs={sinceMs} nowMs={nowMs} />
|
||||
<span className="right row">
|
||||
<span className="muted">{resolved.label}</span>
|
||||
{series.loading && !s ? <Spinner /> : null}
|
||||
</span>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<RangePicker
|
||||
dayStart={period.dayStart}
|
||||
fromMs={period.fromMs}
|
||||
toMs={period.toMs}
|
||||
onChange={changeDay}
|
||||
onWindow={pickLength}
|
||||
scale={period.domainMin > 1440 ? 48 : 24}
|
||||
onScale={setScale}
|
||||
sinceMs={sinceMs}
|
||||
nowMs={nowMs}
|
||||
spanMs={period.spanMs}
|
||||
>
|
||||
<Minimap
|
||||
domainStart={period.domainStart}
|
||||
domainMin={period.domainMin}
|
||||
t={period.overview.t}
|
||||
values={period.overview.loss}
|
||||
fromMs={period.fromMs}
|
||||
toMs={period.toMs}
|
||||
loading={!ready || period.busy}
|
||||
onSelect={selectRange}
|
||||
/>
|
||||
</RangePicker>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PageError error={line.error ?? series.error} />
|
||||
|
||||
{!s && (loadingNow || !line.error) && !series.error ? (
|
||||
// 还没有数据:先摆出占位,每块上面有进度条
|
||||
<div className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="tiles">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div className="skeleton has-loading" key={i}>
|
||||
<LoadingBar active />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton chart has-loading">
|
||||
<LoadingBar active />
|
||||
</div>
|
||||
<div className="grid cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div className="skeleton chart has-loading" key={i}>
|
||||
<LoadingBar active />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{s ? (
|
||||
<div className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="card">
|
||||
<div className="row wrap" style={{ justifyContent: 'space-between', gap: 24 }}>
|
||||
<div className="grid stats grow">
|
||||
<Stat label="延迟 p50" value={fmtMs(m['rtt.p50'])} />
|
||||
<Stat label="延迟 p95" value={fmtMs(m['rtt.p95'])} />
|
||||
<Stat label="丢包" value={fmtPct(m['loss.total'])} tone={tone(m['loss.total'], 0.5, 3)} />
|
||||
<Stat label="上行丢包" value={fmtPct(m['loss.up'])} tone={tone(m['loss.up'], 0.5, 3)} />
|
||||
<Stat label="下行丢包" value={fmtPct(m['loss.down'])} tone={tone(m['loss.down'], 0.5, 3)} />
|
||||
<Stat label="抖动(上 / 下)" value={`${fmtMs(m['jitter.up.p95'])} / ${fmtMs(m['jitter.down.p95'])}`} />
|
||||
</div>
|
||||
<div className="tiles">
|
||||
<div className="tile scores-tile has-loading">
|
||||
<LoadingBar active={loadingNow} />
|
||||
<span className="k">评分</span>
|
||||
<ScoreChips metrics={m} />
|
||||
</div>
|
||||
<hr className="divider" />
|
||||
<div className="grid stats">
|
||||
<Stat label="TCP RTT p50" value={fmtMs(m['tcp.rtt.p50'])} />
|
||||
<Stat label="TCP RTT p99" value={fmtMs(m['tcp.rtt.p99'])} />
|
||||
<Stat label="TCP RTT 最大" value={fmtMs(m['tcp.rtt.max'])} />
|
||||
<Stat label="TCP 重传" value={fmtPct(m['tcp.retrans'])} tone={tone(m['tcp.retrans'], 0.5, 3)} />
|
||||
<Stat label="最长连续丢包" value={fmtNum(m['loss.run_max'])} unit="包" />
|
||||
<Stat label="样本数(UDP / TCP)" value={`${fmtNum(s.summary.samples['udp_echo'])} / ${fmtNum(s.summary.samples['tcp_echo'])}`} />
|
||||
</div>
|
||||
{s.summary.coverage < 0.95 ? <p className="faint" style={{ marginTop: 12 }}>这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
||||
{TILES.map((t) => (
|
||||
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
|
||||
))}
|
||||
</div>
|
||||
<p className="faint">
|
||||
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
|
||||
</p>
|
||||
{s.summary.coverage < 0.95 ? <p className="faint">这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
||||
|
||||
<div className="grid cols-2">
|
||||
{chart('延迟(UDP 往返)', 'ms', [
|
||||
{ label: 'p50', values: col('rtt.p50'), color: '--chart-1' },
|
||||
{ label: 'p95', values: col('rtt.p95'), color: '--chart-2' },
|
||||
{ label: 'p99', values: col('rtt.p99'), color: '--chart-3' },
|
||||
])}
|
||||
{chart('丢包', '%', [
|
||||
{ label: '上行', values: col('loss.up'), color: '--chart-2' },
|
||||
{ label: '下行', values: col('loss.down'), color: '--chart-3' },
|
||||
{ label: '合计', values: col('loss.total'), color: '--chart-1', dash: [4, 3] },
|
||||
])}
|
||||
{chart('抖动(p95)', 'ms', [
|
||||
{ label: '上行', values: col('jitter.up.p95'), color: '--chart-2' },
|
||||
{ label: '下行', values: col('jitter.down.p95'), color: '--chart-1' },
|
||||
])}
|
||||
{chart('TCP 往返', 'ms', [
|
||||
{ label: 'p50', values: col('tcp.rtt.p50'), color: '--chart-1' },
|
||||
{ label: 'p95', values: col('tcp.rtt.p95'), color: '--chart-2' },
|
||||
{ label: '最大', values: col('tcp.rtt.max'), color: '--chart-3' },
|
||||
])}
|
||||
{chart('TCP 重传', '%', [{ label: '重传率', values: col('tcp.retrans'), color: '--chart-3', fill: true }])}
|
||||
{chart('综合评分', '', [
|
||||
{ label: 'REALITY', values: col('score.reality'), color: '--chart-1' },
|
||||
{ label: 'Hy2', values: col('score.hy2'), color: '--chart-2' },
|
||||
{ label: 'MC', values: col('score.mc'), color: '--chart-4' },
|
||||
{ label: '综合', values: col('score.base'), color: '--chart-5', dash: [4, 3] },
|
||||
])}
|
||||
</div>
|
||||
|
||||
{host || hostHistory.data ? (
|
||||
<div className="card">
|
||||
<div className="card-title">主机状态</div>
|
||||
{host ? (
|
||||
<div className="grid stats">
|
||||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
|
||||
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||||
</div>
|
||||
) : null}
|
||||
{hostHistory.data ? (
|
||||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
||||
<TimeChart
|
||||
t={hostHistory.data.t}
|
||||
unit="%"
|
||||
series={[
|
||||
{ label: 'CPU', values: hostHistory.data.values['cpu'] ?? [], color: '--chart-1' },
|
||||
{ label: '内存', values: hostHistory.data.values['mem'] ?? [], color: '--chart-2' },
|
||||
{ label: '磁盘', values: hostHistory.data.values['disk'] ?? [], color: '--chart-4' },
|
||||
]}
|
||||
/>
|
||||
<TimeChart
|
||||
t={hostHistory.data.t}
|
||||
unit=" B/s"
|
||||
series={[
|
||||
{ label: '上行', values: hostHistory.data.values['tx_bps'] ?? [], color: '--chart-2' },
|
||||
{ label: '下行', values: hostHistory.data.values['rx_bps'] ?? [], color: '--chart-1' },
|
||||
]}
|
||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
|
||||
<span className="faint" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">
|
||||
EWMA 平滑
|
||||
</span>
|
||||
<Segmented<EwmaValue>
|
||||
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
|
||||
value={smooth.value}
|
||||
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{chartSections}
|
||||
|
||||
<HostCard agentId={agentId} range={period.hostRange} />
|
||||
|
||||
{test.data && result ? (
|
||||
<div className="card">
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { OverviewDto, OverviewLineDto } from '@shared/api/types'
|
||||
import { fmtAgo, fmtMs, fmtPct } from '@shared/format'
|
||||
import type { NodeInfoDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
|
||||
import { fmtAgo } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
|
||||
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
|
||||
import { OsIcon } from '@shared/osIcon'
|
||||
import { PageError } from '../lib'
|
||||
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
||||
|
||||
const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',')
|
||||
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
||||
|
||||
type SortKey = 'name' | 'rtt' | 'score'
|
||||
type SortKey = 'name' | 'rtt' | 'loss'
|
||||
|
||||
interface AgentCard {
|
||||
id: string
|
||||
@@ -20,12 +23,6 @@ interface AgentCard {
|
||||
lines: OverviewLineDto[]
|
||||
}
|
||||
|
||||
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value <= good) return 'ok'
|
||||
if (value >= poor) return 'bad'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
|
||||
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
|
||||
@@ -41,17 +38,49 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) {
|
||||
function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
||||
const h = host?.host ?? null
|
||||
const stale = !agent.online || !host || host.stale
|
||||
const info = infos.get(agent.id)
|
||||
return (
|
||||
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
|
||||
<div className="agent-head">
|
||||
<Dot tone={agent.online ? 'ok' : 'bad'} />
|
||||
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
||||
{info?.country ? <Flag code={info.country} /> : <Dot tone={agent.online ? 'ok' : 'bad'} />}
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
|
||||
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
|
||||
</div>
|
||||
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
|
||||
<div className="facts">
|
||||
{info.isp_name ? (
|
||||
<div className="fact">
|
||||
<span className="k">运营商</span>
|
||||
<span className="v">
|
||||
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||||
{info.isp_name}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{info.os_name ? (
|
||||
<div className="fact">
|
||||
<span className="k">系统</span>
|
||||
<span className="v">
|
||||
<OsIcon os={info.os} name={info.os_name} />
|
||||
{[info.os_name, info.arch].filter(Boolean).join(' / ')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{info.tag || info.version ? (
|
||||
<div className="fact">
|
||||
<span className="k">版本</span>
|
||||
<span className="v mono-tag" title={info.version ? `版本 ${info.version}` : undefined}>
|
||||
{info.tag || info.version}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{agent.labels.length > 0 ? (
|
||||
<div className="line-meta">
|
||||
{agent.labels.map((x) => (
|
||||
@@ -65,12 +94,21 @@ function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined })
|
||||
const m = l.summary.metrics
|
||||
return (
|
||||
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
|
||||
<span className="muted">→ {l.probe_name}</span>
|
||||
<span className="grow" />
|
||||
<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>
|
||||
{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}
|
||||
<span className="num">{fmtMs(m['rtt.p50'])}</span>
|
||||
<Chip tone={tone(m['loss.total'], 0.5, 3)}>{fmtPct(m['loss.total'])}</Chip>
|
||||
<ScoreChips metrics={m} compact />
|
||||
</div>
|
||||
<LineStrips lineId={l.id} range={range} m={m} />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -113,7 +151,21 @@ export function Overview() {
|
||||
const [probe, setProbe] = useState<string>('')
|
||||
const [sort, setSort] = useState<SortKey>('name')
|
||||
const { hosts, live } = useHosts()
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range: '15m', metrics: METRICS }), [], 10_000)
|
||||
const infos = useNodeInfos()
|
||||
// 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h)
|
||||
const [params, setParams] = useSearchParams()
|
||||
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
|
||||
const setRange = (v: OverviewRange) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (v === '3h') next.delete('range')
|
||||
else next.set('range', v)
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS }), [range], 10_000)
|
||||
|
||||
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
|
||||
const cards = useMemo(() => {
|
||||
@@ -131,13 +183,13 @@ export function Overview() {
|
||||
return [...byAgent.values()].sort((a, b) => {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1
|
||||
if (sort === 'name') return a.name.localeCompare(b.name)
|
||||
const key = sort === 'rtt' ? 'rtt.p50' : 'score.base'
|
||||
const x = best(a, key, sort === 'rtt' ? Math.min : Math.max)
|
||||
const y = best(b, key, sort === 'rtt' ? Math.min : Math.max)
|
||||
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
|
||||
const x = best(a, key, Math.min)
|
||||
const y = best(b, key, Math.min)
|
||||
if (x === null && y === null) return a.name.localeCompare(b.name)
|
||||
if (x === null) return 1
|
||||
if (y === null) return -1
|
||||
return sort === 'rtt' ? x - y : y - x
|
||||
return x - y
|
||||
})
|
||||
}, [data, probe, sort])
|
||||
|
||||
@@ -146,7 +198,7 @@ export function Overview() {
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<h1>线路质量</h1>
|
||||
<h1>总览</h1>
|
||||
<p className="sub">
|
||||
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
@@ -154,11 +206,12 @@ export function Overview() {
|
||||
<div className="toolbar">
|
||||
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
|
||||
<span className="right row">
|
||||
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: 'name' as const, label: '名称' },
|
||||
{ value: 'rtt' as const, label: '延迟' },
|
||||
{ value: 'score' as const, label: '评分' },
|
||||
{ value: 'loss' as const, label: '丢包' },
|
||||
]}
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
@@ -178,7 +231,7 @@ export function Overview() {
|
||||
) : (
|
||||
<div className="cards">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.id} agent={c} host={hosts.get(c.id)} />
|
||||
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} infos={infos} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useMemo } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { SeriesDto } from '@shared/api/types'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { availableWindows, dayBounds, dayKey, defaultWindow, fmtDayLabel, fmtSpan, isWindow, parseDay, WINDOWS, type WindowValue } from './ranges'
|
||||
|
||||
const MIN = 60_000
|
||||
const HOUR = 60 * MIN
|
||||
|
||||
/** URL 里的一段时间:哪一天、快捷档位、或者拖出来的起止分钟(从当天 0 点算)。 */
|
||||
export interface PeriodSpec {
|
||||
day: string | null
|
||||
range: string | null
|
||||
at: string | null
|
||||
/** 缩略图的刻度:缺省连续 48 小时(可以跨零点);`24` = 只看一天。 */
|
||||
span: string | null
|
||||
}
|
||||
|
||||
export function readSpec(params: URLSearchParams): PeriodSpec {
|
||||
return { day: params.get('day'), range: params.get('range'), at: params.get('at'), span: params.get('span') }
|
||||
}
|
||||
|
||||
export function writeSpec(params: URLSearchParams, spec: PeriodSpec) {
|
||||
for (const [key, value] of [['day', spec.day], ['range', spec.range], ['at', spec.at], ['span', spec.span]] as const) {
|
||||
if (value) params.set(key, value)
|
||||
else params.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** `at=起_止`:从选中那天 0 点算起的分钟数,可以是负的(前一天晚上)或超过 1440(跨到后一天);晚高峰常常跨零点。 */
|
||||
export function parseAt(at: string | null): [number, number] | null {
|
||||
const m = /^(-?\d{1,5})_(-?\d{1,5})$/.exec(at ?? '')
|
||||
if (!m) return null
|
||||
const a = Number(m[1])
|
||||
const b = Number(m[2])
|
||||
return b > a && a >= -DOMAIN_MIN && b <= 2 * DOMAIN_MIN && b - a <= 2 * DOMAIN_MIN ? [a, b] : null
|
||||
}
|
||||
|
||||
/** 缩略图的时间轴:连续的 48 小时,选区可以跨零点。 */
|
||||
export const DOMAIN_MIN = 1440
|
||||
export const DOMAIN_SPAN = 2 * DOMAIN_MIN
|
||||
|
||||
export const fmtMinute = (minutes: number) => `${String(Math.floor(minutes / 60) % 24).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
|
||||
|
||||
const fmtClock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
export interface Period {
|
||||
dayStart: number
|
||||
isToday: boolean
|
||||
/** 这一天里的数据概览(缩略图用)。 */
|
||||
overview: { t: number[]; loss: (number | null)[]; rtt: (number | null)[] }
|
||||
/** 缩略图时间轴的起点(毫秒):往日是这一天 0 点起的 48 小时;今天是昨天 0 点起的 48 小时。 */
|
||||
domainStart: number
|
||||
/** 缩略图的刻度长度(分钟):1440 或 2880。 */
|
||||
domainMin: number
|
||||
spanMs: number | null
|
||||
dataFirst: number | null
|
||||
available: WindowValue[]
|
||||
/** 当前选中的快捷档位;拖出来的区间是 `null`。 */
|
||||
win: WindowValue | null
|
||||
/** 选中的区间(毫秒):缩略图上的选区、也是对比时对齐的起点。 */
|
||||
fromMs: number
|
||||
toMs: number
|
||||
query: Record<string, string>
|
||||
hostRange: string | null
|
||||
label: string
|
||||
shortLabel: string
|
||||
/** 缩略图的数据正在加载(不含定时刷新)。 */
|
||||
busy: boolean
|
||||
/** 今天、且是「最近一段」:会跟着最新数据刷新。 */
|
||||
live: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「哪一天 + 看多长或拖出的区间」变成这一段的取数参数和概览。
|
||||
* 今天的快捷档位看最近一段(实时);往日的快捷档位从这一天第一条记录所在的那一分钟起算。
|
||||
*/
|
||||
export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null, nowMs: number, spec: PeriodSpec): Period {
|
||||
const todayStart = dayBounds(nowMs).start
|
||||
const firstDay = sinceMs === null ? todayStart : dayBounds(sinceMs).start
|
||||
const dayStart = Math.min(Math.max(parseDay(spec.day) ?? todayStart, firstDay), todayStart)
|
||||
const isToday = dayStart === todayStart
|
||||
const bounds = dayBounds(dayStart)
|
||||
const dayEnd = isToday ? nowMs : bounds.end
|
||||
|
||||
// 缩略图的数据:连续 48 小时(选区可以跨零点);这一天自己的数据范围和档位从中截取
|
||||
// 刻度:缺省连续 48 小时(选区可以跨零点);用户选了 24 小时就只看这一天,除非选区本身跨了零点
|
||||
const wantedAt = parseAt(spec.at)
|
||||
const wide = spec.span !== '24' || (wantedAt !== null && (wantedAt[1] > DOMAIN_MIN || wantedAt[0] < 0))
|
||||
const domainMin = wide ? DOMAIN_SPAN : DOMAIN_MIN
|
||||
const before = (isToday && wide) || (wantedAt !== null && wantedAt[0] < 0) ? DOMAIN_MIN : 0
|
||||
const domainStart = dayStart - before * MIN
|
||||
const domainEnd = Math.min(domainStart + domainMin * MIN, nowMs)
|
||||
const probe = useQuery(
|
||||
() =>
|
||||
ready
|
||||
? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, {
|
||||
from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(),
|
||||
to: new Date(domainEnd).toISOString(),
|
||||
step: wide ? '2m' : '1m',
|
||||
preset: 'udp',
|
||||
metrics: 'rtt.p50,loss.total',
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
[lineId, domainStart, domainMin, ready, isToday ? Math.floor(nowMs / MIN) : 0],
|
||||
)
|
||||
const overview = useMemo(() => {
|
||||
const t = probe.data?.t ?? []
|
||||
return { t, loss: probe.data?.values['loss.total'] ?? [], rtt: probe.data?.values['rtt.p50'] ?? [] }
|
||||
}, [probe.data])
|
||||
|
||||
// 这一天自己的数据:第一条和最后一条记录
|
||||
let firstIdx = -1
|
||||
let lastIdx = -1
|
||||
for (let i = 0; i < overview.t.length; i++) {
|
||||
if (overview.t[i] < dayStart || overview.t[i] >= dayEnd) continue
|
||||
if (overview.rtt[i] === null || overview.rtt[i] === undefined) continue
|
||||
if (firstIdx < 0) firstIdx = i
|
||||
lastIdx = i
|
||||
}
|
||||
const stepMs = overview.t.length > 1 ? overview.t[1] - overview.t[0] : MIN
|
||||
const dataFirst = firstIdx >= 0 ? overview.t[firstIdx] : null
|
||||
// 哪些长度可选:选区可以跨到前一天/后一天,所以看的是「从这里往前(今天)或往后(往日)一共有多少数据」,不是这一天自己有多少
|
||||
const availSpan = isToday ? (sinceMs === null ? null : nowMs - sinceMs) : dataFirst === null ? null : nowMs - dataFirst
|
||||
const available = availableWindows(availSpan)
|
||||
|
||||
const dayLabel = fmtDayLabel(dayStart, nowMs)
|
||||
const at = wantedAt
|
||||
const wanted = isWindow(spec.range) && available.includes(spec.range) ? spec.range : null
|
||||
const win: WindowValue | null = at ? null : (wanted ?? defaultWindow(isToday, available))
|
||||
|
||||
let fromMs: number
|
||||
let toMs: number
|
||||
let query: Record<string, string>
|
||||
let hostRange: string | null = null
|
||||
let label: string
|
||||
let live = false
|
||||
if (at) {
|
||||
fromMs = bounds.start + at[0] * MIN
|
||||
toMs = Math.min(bounds.start + at[1] * MIN, nowMs)
|
||||
if (toMs <= fromMs) toMs = fromMs + MIN
|
||||
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
||||
label = fmtSpan(fromMs, toMs)
|
||||
} else if (win === 'day') {
|
||||
fromMs = bounds.start
|
||||
toMs = dayEnd
|
||||
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
||||
hostRange = isToday ? '24h' : null
|
||||
label = `${dayLabel} 全天`
|
||||
} else {
|
||||
const info = WINDOWS.find((w) => w.value === win)!
|
||||
if (isToday) {
|
||||
// 今天:最近一段,以最新的数据为终点
|
||||
const lastMs = lastIdx >= 0 ? overview.t[lastIdx] + stepMs : nowMs
|
||||
toMs = Math.min(nowMs, lastMs)
|
||||
fromMs = toMs - info.ms
|
||||
query = { range: info.value, anchor: 'latest' }
|
||||
hostRange = info.ms <= HOUR ? '1h' : info.value
|
||||
label = `${dayLabel} 最近 ${info.label}`
|
||||
live = true
|
||||
} else {
|
||||
// 往日:从这一天第一条记录所在的那一分钟起
|
||||
fromMs = Math.floor((dataFirst ?? bounds.start) / MIN) * MIN
|
||||
toMs = Math.min(fromMs + info.ms, bounds.end)
|
||||
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
||||
label = `${dayLabel} ${fmtClock(fromMs)} 起 ${info.label}`
|
||||
}
|
||||
}
|
||||
const shortLabel = fmtSpan(fromMs, toMs, true)
|
||||
return { dayStart, isToday, domainStart, domainMin, overview, spanMs: availSpan, dataFirst, available, win, fromMs, toMs, query, hostRange, label, shortLabel, busy: probe.busy, live }
|
||||
}
|
||||
|
||||
/** 这一段的序列;今天的「最近一段」会定时刷新。 */
|
||||
export function usePeriodSeries(lineId: string, ready: boolean, period: Period) {
|
||||
return useQuery(
|
||||
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
|
||||
[lineId, JSON.stringify(period.query), ready],
|
||||
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export { dayKey }
|
||||
+124
-134
@@ -1,11 +1,12 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { Segmented } from '@shared/ui'
|
||||
|
||||
const MIN = 60_000
|
||||
const DAY = 24 * 60 * MIN
|
||||
const HOUR = 60 * MIN
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */
|
||||
/** 对比页的时间范围:最近一段。 */
|
||||
export const PRESETS = [
|
||||
{ value: '5m', label: '5 分钟', ms: 5 * MIN },
|
||||
{ value: '15m', label: '15 分钟', ms: 15 * MIN },
|
||||
@@ -19,172 +20,161 @@ export const PRESETS = [
|
||||
|
||||
export type PresetValue = (typeof PRESETS)[number]['value']
|
||||
|
||||
/** 进入某个月之后的切片:整月,或者从月初起按固定长度翻页。 */
|
||||
export const SLICES = [
|
||||
{ value: 'all', label: '整月', ms: 0 },
|
||||
{ value: '14d', label: '14 天', ms: 14 * DAY },
|
||||
{ value: '7d', label: '7 天', ms: 7 * DAY },
|
||||
{ value: '3d', label: '3 天', ms: 3 * DAY },
|
||||
{ value: '1d', label: '1 天', ms: DAY },
|
||||
/** 线路详情页:先选哪一天,再选这一天里看多长。`day` = 整个自然日。 */
|
||||
export const WINDOWS = [
|
||||
{ value: '30m', label: '30 分钟', ms: 30 * MIN },
|
||||
{ value: '1h', label: '1 小时', ms: HOUR },
|
||||
{ value: '3h', label: '3 小时', ms: 3 * HOUR },
|
||||
{ value: '6h', label: '6 小时', ms: 6 * HOUR },
|
||||
{ value: '12h', label: '12 小时', ms: 12 * HOUR },
|
||||
{ value: 'day', label: '整天', ms: DAY },
|
||||
] as const
|
||||
|
||||
export type SliceValue = (typeof SLICES)[number]['value']
|
||||
export type WindowValue = (typeof WINDOWS)[number]['value']
|
||||
|
||||
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。至少保留最小的一档。 */
|
||||
const TOLERANCE = 0.9
|
||||
|
||||
export function availablePresets(spanMs: number | null) {
|
||||
if (spanMs === null) return [PRESETS[0]]
|
||||
const list = PRESETS.filter((p) => spanMs >= p.ms * TOLERANCE)
|
||||
return list.length > 0 ? list : [PRESETS[0]]
|
||||
}
|
||||
|
||||
/** 默认选“有数据的最大档”,但最长到 1 天(再长的范围图会很挤,用户可以自己点)。 */
|
||||
export function defaultPreset(spanMs: number | null): PresetValue {
|
||||
const list = availablePresets(spanMs).filter((p) => p.ms <= DAY)
|
||||
return list[list.length - 1]?.value ?? '5m'
|
||||
}
|
||||
|
||||
export interface MonthInfo {
|
||||
key: string // YYYY-MM
|
||||
label: string
|
||||
start: number
|
||||
end: number // 不含
|
||||
}
|
||||
export const isWindow = (v: string | null): v is WindowValue => WINDOWS.some((w) => w.value === v)
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
export function monthOf(ms: number): MonthInfo {
|
||||
/** 本地时区的 YYYY-MM-DD。 */
|
||||
export function dayKey(ms: number): string {
|
||||
const d = new Date(ms)
|
||||
const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
|
||||
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
|
||||
return { key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`, label: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`, start, end }
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 从有数据的第一个月到当前月,新的在前。 */
|
||||
export function monthsWithData(sinceMs: number | null, nowMs: number): MonthInfo[] {
|
||||
if (sinceMs === null) return []
|
||||
const months: MonthInfo[] = []
|
||||
let cursor = monthOf(sinceMs)
|
||||
const last = monthOf(nowMs)
|
||||
while (cursor.start <= last.start) {
|
||||
months.push(cursor)
|
||||
cursor = monthOf(cursor.end)
|
||||
}
|
||||
return months.reverse()
|
||||
export function parseDay(key: string | null): number | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key ?? '')
|
||||
if (!m) return null
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||
return Number.isNaN(d.getTime()) ? null : d.getTime()
|
||||
}
|
||||
|
||||
export interface RangeState {
|
||||
/** 没有 month 就是“最近一段”。 */
|
||||
preset: PresetValue
|
||||
month: string | null
|
||||
slice: SliceValue
|
||||
page: number
|
||||
}
|
||||
|
||||
export interface ResolvedRange {
|
||||
query: { range?: string; from?: string; to?: string; anchor?: string }
|
||||
/** 主机历史只支持 ≥ 1 小时的 range,这里给一个对应的值。 */
|
||||
hostRange: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const fmtDay = (ms: number) => {
|
||||
/** 本地时区这一天的 [起点, 终点)。 */
|
||||
export function dayBounds(ms: number): { start: number; end: number } {
|
||||
const d = new Date(ms)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
return { start: new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(), end: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() }
|
||||
}
|
||||
|
||||
export function resolveRange(state: RangeState, months: MonthInfo[], nowMs: number): ResolvedRange {
|
||||
if (!state.month) {
|
||||
const hostRange = state.preset === '5m' || state.preset === '15m' || state.preset === '30m' ? '1h' : state.preset
|
||||
return { query: { range: state.preset, anchor: 'latest' }, hostRange, label: PRESETS.find((p) => p.value === state.preset)?.label ?? state.preset }
|
||||
}
|
||||
const month = months.find((m) => m.key === state.month) ?? monthOf(nowMs)
|
||||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||||
const end = Math.min(month.end, nowMs)
|
||||
let from = month.start
|
||||
let to = end
|
||||
if (slice.ms > 0) {
|
||||
from = month.start + state.page * slice.ms
|
||||
to = Math.min(from + slice.ms, end)
|
||||
}
|
||||
const spanDays = Math.max(1, Math.round((to - from) / DAY))
|
||||
return {
|
||||
query: { from: new Date(from).toISOString(), to: new Date(to).toISOString() },
|
||||
hostRange: spanDays >= 30 ? '30d' : `${Math.max(1, spanDays)}d`,
|
||||
label: slice.ms > 0 ? `${month.label} ${fmtDay(from)} – ${fmtDay(to - 1)}` : month.label,
|
||||
}
|
||||
const clock = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}`
|
||||
const fullDay = (ms: number) => `${new Date(ms).getFullYear()}年${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日`
|
||||
const shortDay = (ms: number) => `${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日`
|
||||
|
||||
/** 一段时间的文字:同一天写一次日期(2026年10月5日 21:06–23:00),跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 */
|
||||
export function fmtSpan(fromMs: number, toMs: number, short = false): string {
|
||||
const day = short ? shortDay : fullDay
|
||||
const end = Math.max(fromMs, toMs - 1)
|
||||
// 正好到次日 0 点的写成 24:00
|
||||
return dayKey(fromMs) === dayKey(end) ? `${day(fromMs)} ${clock(fromMs)}–${clock(toMs) === '00:00' ? '24:00' : clock(toMs)}` : `${day(fromMs)} ${clock(fromMs)} – ${day(end)} ${clock(toMs)}`
|
||||
}
|
||||
|
||||
/** 一个月里按固定长度能翻几页。 */
|
||||
function pagesIn(month: MonthInfo, sliceMs: number, nowMs: number, sinceMs: number | null): { first: number; last: number } {
|
||||
const start = Math.max(month.start, sinceMs === null ? month.start : sinceMs - (sinceMs % DAY))
|
||||
const end = Math.min(month.end, nowMs)
|
||||
return { first: Math.max(0, Math.floor((start - month.start) / sliceMs)), last: Math.max(0, Math.ceil((end - month.start) / sliceMs) - 1) }
|
||||
export const fmtDayLabel = (startMs: number, nowMs: number) => {
|
||||
const d = new Date(startMs)
|
||||
const today = dayKey(startMs) === dayKey(nowMs)
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日${today ? '(今天)' : ''}`
|
||||
}
|
||||
|
||||
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。 */
|
||||
const TOLERANCE = 0.9
|
||||
|
||||
/** 这一天里有多长的数据(毫秒),决定哪些档位可选;`null` = 这一天没有数据。 */
|
||||
export function availableWindows(spanMs: number | null): WindowValue[] {
|
||||
if (spanMs === null) return []
|
||||
const list = WINDOWS.filter((w) => w.value === 'day' || spanMs >= w.ms * TOLERANCE).map((w) => w.value)
|
||||
return list.length > 0 ? list : ['day']
|
||||
}
|
||||
|
||||
/** 默认:今天看最近 1 小时,往日看整天。 */
|
||||
export function defaultWindow(isToday: boolean, available: WindowValue[]): WindowValue {
|
||||
if (!isToday) return 'day'
|
||||
return available.includes('1h') ? '1h' : (available.filter((w) => w !== 'day').pop() ?? 'day')
|
||||
}
|
||||
|
||||
interface Props {
|
||||
state: RangeState
|
||||
onChange: (next: RangeState) => void
|
||||
/** 选中那天的 0 点(本地时区)。 */
|
||||
dayStart: number
|
||||
/** 选区(毫秒):长度正好等于某一档时,那一档高亮。 */
|
||||
fromMs: number
|
||||
toMs: number
|
||||
/** 换了一天(选区回到默认)。 */
|
||||
onChange: (next: { dayStart: number; window: WindowValue | null }) => void
|
||||
/** 点了一档时长:30 分钟、1 小时、3 小时、6 小时、12 小时、整天。 */
|
||||
onWindow: (window: WindowValue) => void
|
||||
/** 缩略图的刻度:24 小时(一天)或 48 小时(连续两天,可以跨零点)。 */
|
||||
scale: 24 | 48
|
||||
onScale: (scale: 24 | 48) => void
|
||||
/** 这条线路最早有数据的时间;`null` = 没有数据。 */
|
||||
sinceMs: number | null
|
||||
nowMs: number
|
||||
/** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */
|
||||
spanMs: number | null
|
||||
/** 放在日期和档位下面(缩略图)。 */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function RangePicker({ state, onChange, sinceMs, nowMs }: Props) {
|
||||
const spanMs = sinceMs === null ? null : nowMs - sinceMs
|
||||
const presets = useMemo(() => availablePresets(spanMs), [spanMs])
|
||||
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
|
||||
const month = state.month ? months.find((m) => m.key === state.month) : undefined
|
||||
const monthLength = month ? Math.min(month.end, nowMs) - Math.max(month.start, sinceMs ?? month.start) : 0
|
||||
const slices = SLICES.filter((s) => s.ms === 0 || monthLength >= s.ms * TOLERANCE)
|
||||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||||
const pages = month && slice.ms > 0 ? pagesIn(month, slice.ms, nowMs, sinceMs) : undefined
|
||||
export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) {
|
||||
const picker = useRef<HTMLInputElement>(null)
|
||||
const today = dayBounds(nowMs).start
|
||||
const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start
|
||||
const available = availableWindows(spanMs)
|
||||
const length = toMs - fromMs
|
||||
const active: WindowValue = WINDOWS.find((w) => Math.abs(length - w.ms) < 1.5 * MIN)?.value ?? ('' as WindowValue)
|
||||
const step = (days: number) => {
|
||||
const d = new Date(dayStart)
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days).getTime()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="range-picker">
|
||||
<div className="row wrap">
|
||||
<Segmented
|
||||
options={PRESETS.map((p) => {
|
||||
const ok = presets.some((x) => x.value === p.value)
|
||||
return { value: p.value, label: p.label, disabled: !ok, title: ok ? undefined : '这条线路的数据还没有这么长' }
|
||||
})}
|
||||
value={state.month ? ('' as PresetValue) : state.preset}
|
||||
onChange={(v) => onChange({ ...state, preset: v, month: null, page: 0 })}
|
||||
/>
|
||||
{months.length > 0 ? (
|
||||
<select
|
||||
className="select"
|
||||
style={{ width: 'auto' }}
|
||||
disabled={spanMs === null || spanMs < DAY}
|
||||
title={spanMs !== null && spanMs >= DAY ? undefined : '数据超过 1 天之后可以按月查看'}
|
||||
value={state.month ?? ''}
|
||||
onChange={(e) => onChange({ ...state, month: e.target.value || null, slice: 'all', page: 0 })}
|
||||
aria-label="按月查看"
|
||||
>
|
||||
<option value="">按月查看</option>
|
||||
{months.map((m) => (
|
||||
<option key={m.key} value={m.key}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</div>
|
||||
{month ? (
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Segmented options={slices.map((s) => ({ value: s.value, label: s.label }))} value={state.slice} onChange={(v) => onChange({ ...state, slice: v, page: 0 })} />
|
||||
{pages ? (
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<button type="button" className="btn icon small" disabled={state.page <= pages.first} onClick={() => onChange({ ...state, page: state.page - 1 })} aria-label="上一段">
|
||||
<button type="button" className="btn icon small" disabled={dayStart <= firstDay} onClick={() => onChange({ dayStart: step(-1), window: null })} aria-label="前一天">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" className="btn icon small" disabled={state.page >= pages.last} onClick={() => onChange({ ...state, page: state.page + 1 })} aria-label="下一段">
|
||||
<button type="button" className="day-pick" onClick={() => picker.current?.showPicker?.()}>
|
||||
{fmtDayLabel(dayStart, nowMs)}
|
||||
<input
|
||||
ref={picker}
|
||||
type="date"
|
||||
value={dayKey(dayStart)}
|
||||
min={dayKey(firstDay)}
|
||||
max={dayKey(today)}
|
||||
onChange={(e) => {
|
||||
const picked = parseDay(e.target.value)
|
||||
if (picked !== null) onChange({ dayStart: Math.min(Math.max(picked, firstDay), today), window: null })
|
||||
}}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</button>
|
||||
<button type="button" className="btn icon small" disabled={dayStart >= today} onClick={() => onChange({ dayStart: step(1), window: null })} aria-label="后一天">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</span>
|
||||
{dayStart !== today ? (
|
||||
<button type="button" className="btn small" onClick={() => onChange({ dayStart: today, window: null })}>
|
||||
回到今天
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
<div className="row wrap" style={{ marginTop: 8, justifyContent: 'space-between' }}>
|
||||
<Segmented
|
||||
options={WINDOWS.map((w) => {
|
||||
const ok = available.includes(w.value)
|
||||
return { value: w.value, label: w.label, disabled: !ok, title: ok ? undefined : '这一天的数据没有这么长' }
|
||||
})}
|
||||
value={active}
|
||||
onChange={onWindow}
|
||||
/>
|
||||
<Segmented<'24' | '48'>
|
||||
options={[
|
||||
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
|
||||
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
|
||||
]}
|
||||
value={String(scale) as '24' | '48'}
|
||||
onChange={(v) => onScale(v === '48' ? 48 : 24)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { buildDefines } from '../build-info.mjs'
|
||||
|
||||
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
|
||||
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:29181').replace(/\/+$/, '')
|
||||
return {
|
||||
base: '/',
|
||||
define: buildDefines(mode === 'development'),
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
fs: { allow: ['..'] },
|
||||
proxy: {
|
||||
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
|
||||
'/admin': { target: 'http://localhost:5175', changeOrigin: true, ws: true },
|
||||
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
},
|
||||
|
||||
Generated
+30
-1
@@ -10,7 +10,11 @@
|
||||
"workspaces": [
|
||||
"display",
|
||||
"admin"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"flag-icons": "^7.5.0",
|
||||
"simple-icons": "^16.34.0"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"name": "monitor-admin",
|
||||
@@ -998,6 +1002,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "6.1.23",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
|
||||
@@ -2089,6 +2099,25 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-icons": {
|
||||
"version": "16.34.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.34.0.tgz",
|
||||
"integrity": "sha512-UTxKn0PVspk84B2hHF3Dqiqht1yDjJmDc/t4cO7PyCQxCMk6OjEu95pnpGjhLaGeQ4qBrGASbefTIe/+czrX7g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/simple-icons"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/simple-icons"
|
||||
}
|
||||
],
|
||||
"license": "CC0-1.0",
|
||||
"engines": {
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
|
||||
+8
-1
@@ -2,7 +2,10 @@
|
||||
"name": "monitor-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"workspaces": ["display", "admin"],
|
||||
"workspaces": [
|
||||
"display",
|
||||
"admin"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node dev.mjs",
|
||||
"dev:display": "npm run dev -w display",
|
||||
@@ -10,5 +13,9 @@
|
||||
"build": "npm run build -w display && npm run build -w admin",
|
||||
"gen:api": "npx -y openapi-typescript ../backend/openapi/v1.json -o shared/api/schema.ts",
|
||||
"lint:css": "npm run lint:css -w display && npm run lint:css -w admin"
|
||||
},
|
||||
"dependencies": {
|
||||
"flag-icons": "^7.5.0",
|
||||
"simple-icons": "^16.34.0"
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,28 @@ async function request<T>(method: string, path: string, body?: unknown, init?: R
|
||||
return data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* 在另一个后端上用用户名和密码登录:不设 Cookie(跨域用不了),把会话令牌放在响应里返回(`return_session`),
|
||||
* 调用方存进浏览器,之后每个请求带上。
|
||||
*/
|
||||
export async function loginTo(base: string, username: string, password: string): Promise<string> {
|
||||
const response = await fetch(`${base}/api/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify({ username, password, return_session: true }),
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = text ? (JSON.parse(text) as unknown) : undefined
|
||||
if (!response.ok) {
|
||||
const problem = (data ?? {}) as { code?: string; detail?: string; title?: string }
|
||||
throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText)
|
||||
}
|
||||
const token = (data as { session_token?: string | null } | undefined)?.session_token
|
||||
if (!token) throw new ApiError(502, 'no_session', '这个后端的版本还不支持跨域登录,请先升级后端')
|
||||
return token
|
||||
}
|
||||
|
||||
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 ?? {}),
|
||||
|
||||
+200
-3
@@ -174,6 +174,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/public/nodes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Display info of the visible agents and probes: system, architecture, country and ISP (never an address) */
|
||||
get: operations["nodes_list"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/public/about": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** The backend's version, tag and build time, if the site shows build info */
|
||||
get: operations["about_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/public/catalog/kinds": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -442,7 +476,7 @@ export interface paths {
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Revoke a join token */
|
||||
/** Revoke a join token; with purge=true delete the record for good */
|
||||
delete: operations["join_tokens_revoke"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -765,6 +799,23 @@ export interface paths {
|
||||
patch: operations["users_update"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/build-info": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Version, tag, commit and build time of the backend and every agent and probe */
|
||||
get: operations["build_info_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/healthz": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -854,6 +905,10 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
AboutDto: {
|
||||
show_build: boolean;
|
||||
backend: null | components["schemas"]["BuildDto"];
|
||||
};
|
||||
AgentDto: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -915,6 +970,16 @@ export interface components {
|
||||
tcp_port: number | string;
|
||||
spki: string;
|
||||
};
|
||||
BuildDto: {
|
||||
version: string;
|
||||
tag: null | string;
|
||||
commit: null | string;
|
||||
built_at: null | string;
|
||||
};
|
||||
BuildInfoDto: {
|
||||
backend: components["schemas"]["BuildDto"];
|
||||
nodes: components["schemas"]["NodeBuildDto"][];
|
||||
};
|
||||
ChangePasswordRequest: {
|
||||
password: string;
|
||||
};
|
||||
@@ -986,7 +1051,7 @@ export interface components {
|
||||
CreatedJoinTokenDto: {
|
||||
token: components["schemas"]["JoinTokenDto"];
|
||||
plaintext: string;
|
||||
install_command: string;
|
||||
install_commands: components["schemas"]["InstallCommandsDto"];
|
||||
};
|
||||
DimensionDto: {
|
||||
id: string;
|
||||
@@ -1112,6 +1177,11 @@ export interface components {
|
||||
[key: string]: (null | number | string)[];
|
||||
};
|
||||
};
|
||||
InstallCommandsDto: {
|
||||
linux: string;
|
||||
macos: string;
|
||||
windows: string;
|
||||
};
|
||||
JoinTokenDto: {
|
||||
id: string;
|
||||
note: null | string;
|
||||
@@ -1164,6 +1234,7 @@ export interface components {
|
||||
LoginRequest: {
|
||||
username: string;
|
||||
password: string;
|
||||
return_session: null | boolean;
|
||||
};
|
||||
LoginResponse: {
|
||||
user_id: string;
|
||||
@@ -1171,6 +1242,7 @@ export interface components {
|
||||
csrf_token: string;
|
||||
/** Format: date-time */
|
||||
expires_at: unknown;
|
||||
session_token: null | string;
|
||||
};
|
||||
MeDto: {
|
||||
kind: string;
|
||||
@@ -1202,6 +1274,29 @@ export interface components {
|
||||
version: number | string;
|
||||
needs: string[];
|
||||
};
|
||||
NodeBuildDto: {
|
||||
id: string;
|
||||
kind: string;
|
||||
os: null | string;
|
||||
os_name: null | string;
|
||||
arch: null | string;
|
||||
version: null | string;
|
||||
tag: null | string;
|
||||
commit: null | string;
|
||||
built_at: null | string;
|
||||
};
|
||||
NodeInfoDto: {
|
||||
id: string;
|
||||
kind: string;
|
||||
os: null | string;
|
||||
os_name: null | string;
|
||||
arch: null | string;
|
||||
country: null | string;
|
||||
isp_key: null | string;
|
||||
isp_name: null | string;
|
||||
version?: null | string;
|
||||
tag?: null | string;
|
||||
};
|
||||
OverviewDto: {
|
||||
range: string;
|
||||
/** Format: date-time */
|
||||
@@ -1259,6 +1354,10 @@ export interface components {
|
||||
items: components["schemas"]["ModelDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfNodeInfoDto: {
|
||||
items: components["schemas"]["NodeInfoDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfPlanDto: {
|
||||
items: components["schemas"]["PlanDto"][];
|
||||
next_cursor: null | string;
|
||||
@@ -2126,6 +2225,64 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
nodes_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"]["PageDtoOfNodeInfoDto"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
about_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AboutDto"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
catalog_kinds: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2902,7 +3059,9 @@ export interface operations {
|
||||
};
|
||||
join_tokens_revoke: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
purge?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
@@ -4361,6 +4520,44 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
build_info_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BuildInfoDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
health_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -41,17 +41,65 @@ export function getBase(): string {
|
||||
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
|
||||
}
|
||||
|
||||
export interface SavedSource {
|
||||
base: string
|
||||
/** 在这个后端登录之后拿到的会话(只存在浏览器里);空 = 还没登录。 */
|
||||
token: string
|
||||
}
|
||||
|
||||
// v2:以前保存的是手填的管理 Token,现在是登录后拿到的会话,旧的不再使用
|
||||
const listKey = () => `monitor.sources.v2.${app}`
|
||||
|
||||
function readList(): SavedSource[] {
|
||||
try {
|
||||
const parsed = JSON.parse(read(listKey()) || '[]') as unknown
|
||||
return Array.isArray(parsed) ? (parsed as SavedSource[]).filter((x) => typeof x?.base === 'string' && x.base !== '') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存过的后端(每个前端各存各的登录会话);当前连接的那个一定在里面。不含这个页面自己所在的后端。 */
|
||||
export function listSources(): SavedSource[] {
|
||||
const list = readList()
|
||||
const active = getBase()
|
||||
if (active && !list.some((x) => x.base === active)) list.push({ base: active, token: '' })
|
||||
return list
|
||||
}
|
||||
|
||||
function writeList(list: SavedSource[]) {
|
||||
write(listKey(), JSON.stringify(list))
|
||||
}
|
||||
|
||||
/** 当前连接的后端的登录会话;这个页面自己所在的后端用 Cookie,没有。 */
|
||||
export function getToken(): string {
|
||||
return read(`monitor.api_token.${app}`)
|
||||
const active = getBase()
|
||||
return active ? (readList().find((x) => x.base === active)?.token ?? '') : ''
|
||||
}
|
||||
|
||||
/** 保存(或更新)一个后端和它的登录会话,并连过去。地址留空 = 这个页面自己所在的后端。 */
|
||||
export function setSource(base: string, token: string) {
|
||||
write(BASE_KEY, normalizeBase(base))
|
||||
write(`monitor.api_token.${app}`, token.trim())
|
||||
const next = normalizeBase(base)
|
||||
if (next) writeList([...readList().filter((x) => x.base !== next), { base: next, token }])
|
||||
write(BASE_KEY, next)
|
||||
}
|
||||
|
||||
/** 切换到已经保存过的后端(空串 = 这个页面自己所在的后端)。 */
|
||||
export function activateSource(base: string) {
|
||||
write(BASE_KEY, normalizeBase(base))
|
||||
}
|
||||
|
||||
/** 忘掉一个保存过的后端;正连着的话回到这个页面自己所在的后端。 */
|
||||
export function removeSource(base: string) {
|
||||
writeList(readList().filter((x) => x.base !== base))
|
||||
if (getBase() === base) write(BASE_KEY, '')
|
||||
}
|
||||
|
||||
/** 只改当前连接的后端的登录会话(过期、退出时清掉)。 */
|
||||
export function setToken(token: string) {
|
||||
write(`monitor.api_token.${app}`, token.trim())
|
||||
const active = getBase()
|
||||
if (!active) return
|
||||
writeList([...readList().filter((x) => x.base !== active), { base: active, token }])
|
||||
}
|
||||
|
||||
export const isRemote = () => getBase() !== ''
|
||||
@@ -66,6 +114,18 @@ export function authHeaders(): Record<string, string> {
|
||||
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前后端连不连得上:只看有没有响应(跨域时拿不到内容也没关系,所以用 no-cors);网络不通、域名解析失败、连接被拒才算失败。
|
||||
*/
|
||||
export async function pingBackend(): Promise<boolean> {
|
||||
try {
|
||||
await fetch(resolveUrl('/healthz'), { mode: 'no-cors', cache: 'no-store' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface LiveSource {
|
||||
onopen: (() => void) | null
|
||||
onerror: (() => void) | null
|
||||
|
||||
@@ -12,6 +12,11 @@ export type ProbeDto = Narrow<S['ProbeDto']>
|
||||
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
|
||||
export type ProbeIdentityDto = Narrow<S['ProbeIdentityDto']>
|
||||
export type LineDto = Narrow<S['LineDto']>
|
||||
export type NodeInfoDto = Narrow<S['NodeInfoDto']>
|
||||
export type AboutDto = Narrow<S['AboutDto']>
|
||||
export type BuildDto = Narrow<S['BuildDto']>
|
||||
export type BuildInfoDto = Narrow<S['BuildInfoDto']>
|
||||
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
|
||||
export type OverviewDto = Narrow<S['OverviewDto']>
|
||||
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
|
||||
export type SeriesDto = Narrow<S['SeriesDto']>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
|
||||
|
||||
viewBox="0 0 315 308.5899"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||
sodipodi:docname="China Mobile.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="0.33541667"
|
||||
inkscape:cx="894.40994"
|
||||
inkscape:cy="76.024845"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="705"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="g147"
|
||||
transform="translate(-4.2522286e-4,-4.801313e-4)"
|
||||
style="fill:none">
|
||||
<path
|
||||
id="path4"
|
||||
d="m 303.94434,190.02541 c -27.51878,-36.39255 -68.62753,-73.63677 -79.99218,-82.84938 -7.95801,-6.44699 -15.87023,-9.688803 -23.68172,-9.578911 -7.83896,0.09158 -15.39403,3.333391 -22.09744,9.157661 -1.33702,1.16303 -3.26929,2.87551 -5.64112,4.99093 -16.34643,13.77312 -66.83262,60.18416 -67.97733,61.26476 -3.83706,3.58065 -8.736412,5.93417 -14.240168,5.74186 -4.862719,-0.20147 -10.174163,-2.381 -15.63213,-7.42687 C 70.92761,167.85471 53.674573,154.31053 33.518558,131.65447 21.109924,117.70735 10.871657,105.28956 10.871657,105.28956 c 0,0 -2.2711,4.67956 -3.5440151,7.94885 -1.0622889,2.71982 -0.1465226,4.42315 0.3663065,5.16492 6.7400396,9.66133 37.5006296,46.46598 79.8914506,83.00505 7.774855,6.69426 15.870231,9.68881 23.690871,9.58808 7.8298,-0.10074 15.40319,-3.33339 22.08829,-9.15766 1.33701,-1.17219 3.26928,-2.88467 5.65027,-5.00009 16.33727,-13.76397 46.93302,-41.80473 61.42961,-55.25734 3.10444,-2.80224 5.39386,-4.9085 6.54772,-6.00742 3.82791,-3.58065 8.73641,-5.93417 14.23101,-5.7327 4.86272,0.19231 9.98185,2.59162 15.64129,7.42686 9.06609,7.73823 23.75498,21.3282 41.15454,39.671 12.80241,13.48924 22.57364,26.09018 22.67437,26.22755 0,0 2.38099,-4.84441 3.64475,-8.15948 1.04397,-2.72898 0.14652,-4.27663 -0.39378,-4.98177"
|
||||
style="fill:#8ec320;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766" />
|
||||
<path
|
||||
id="path5"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="M 156.10156,0.03131407 C 137.03789,0.52973098 114.77341,7.1359501 93.236325,24.546939 64.362242,47.871483 27.182379,82.203304 17.749996,92.560611 c 0,0 7.032042,8.745959 18.076172,19.570309 2.133733,1.82237 4.385744,1.3185 6.253907,-0.65039 28.562721,-27.582854 59.508025,-55.725024 77.457035,-69.617185 30.29351,-23.416119 65.82473,-25.686148 100.45898,1.988282 12.89398,10.320675 24.34062,20.521892 41.0625,37.738281 21.38312,23.306232 35.33966,42.775662 50.60547,64.726562 0,0 -0.0636,-5.87932 -1.75781,-13.68164 -0.28389,-1.31871 -0.87122,-3.46178 -1.81446,-5.35742 -3.0678,-6.1631 -16.5557,-29.294843 -36.13476,-52.152346 C 233.13773,29.78552 208.6403,15.974983 192.92578,7.7422516 183.81906,2.9505093 170.92886,-0.35634353 156.10156,0.03131407 Z M 174.20312,44.796939 C 163.23454,44.687047 151.8957,47.985282 140.57226,56.250064 126.3779,66.625686 67.914597,120.91294 56.449215,133.8985 c 0,0 8.233,9.0834 20.56836,19.3125 3.19602,2.73814 7.079332,2.16165 10.421875,-1.19922 22.50952,-22.63771 68.45213,-63.150567 74.61523,-68.0957 17.32628,-14.707192 37.50228,-15.999325 55.98243,-2.16211 44.86334,33.60859 84.54217,86.49463 91.35546,96.35742 0,0 1.02645,-4.87142 1.63086,-9.23047 0.0366,-0.25642 0.87973,-4.18627 -1.37304,-7.49218 -42.07026,-60.76103 -86.85211,-98.241836 -104.04102,-107.591801 -9.8399,-5.370964 -20.43767,-8.890108 -31.40625,-9 z" />
|
||||
<path
|
||||
id="path7"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="m 2.2636683,130.48249 c 0,0 -1.0264543,4.87143 -1.63085941,9.23046 -0.027473,0.24726 -0.86997028,4.17578 1.38281251,7.5 42.0794186,60.74272 86.8527046,98.24319 104.0507786,107.58399 19.67064,10.73277 42.37131,14.06667 65.02735,-2.46289 14.19436,-10.36646 72.65766,-64.66228 84.12304,-77.63867 0,0 -8.2336,-9.0939 -20.57812,-19.30469 -3.19602,-2.7473 -7.07872,-2.1603 -10.41211,1.19141 -22.50952,22.63771 -68.45275,63.15056 -74.625,68.0957 -17.31712,14.70718 -37.49252,16.00787 -55.972657,2.15234 C 48.765558,193.22156 9.0952779,140.35444 2.2636683,130.48249 Z m -2.26171879,31.79492 c 0,0 -0.11950648,5.6687 1.75781249,13.68164 0.3022026,1.30955 0.8601041,3.46178 1.8125,5.35742 3.067814,6.16309 16.557047,29.29484 36.126953,52.15234 38.819293,45.33039 63.316715,59.14032 79.031255,67.37305 20.81534,10.96171 61.40325,14.15734 99.6914,-16.80469 28.86492,-23.31538 66.06176,-57.6466 75.49414,-68.0039 0,0 -7.03204,-8.75513 -18.07617,-19.57032 -2.13374,-1.82236 -4.3955,-1.32765 -6.26367,0.6504 -28.56272,27.5737 -59.50803,55.72381 -77.45703,69.59765 -30.29352,23.43444 -65.8339,25.70642 -100.458987,-1.98633 C 78.775334,254.41315 67.319528,244.21119 50.597653,227.00397 29.214531,203.68858 15.258601,184.23746 0.00194951,162.27741 Z" />
|
||||
</g>
|
||||
<g
|
||||
id="g148"
|
||||
style="fill:#0086d0;fill-opacity:1"
|
||||
transform="translate(-4.2522286e-4,-4.801313e-4)">
|
||||
<path
|
||||
id="path9"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="m 781.3418,15.791016 -0.51172,1.0625 c -7.17959,14.47825 -17.61958,27.327479 -30.95313,38.261718 -0.13736,0.119049 6.89468,5.513116 11.01563,8.123047 8.04042,-7.152128 14.6536,-14.2769 19.64453,-21.154297 h 39.14844 c -6.91404,8.992816 -13.68987,16.327529 -21.49219,21.4375 -5.16492,-4.587984 -9.61716,-8.250232 -16.26563,-13.460937 l -0.94335,-0.734375 -8.42383,9.140625 1.09961,0.861328 c 5.93416,4.871871 9.33907,7.892712 13.24023,11.601563 -10.23825,5.549537 -21.46383,9.935901 -34.27539,13.736328 0,0 8.3869,11.392965 8.625,11.310546 7.61001,-2.655719 23.06881,-9.523504 29.75391,-12.554687 -8.8463,17.463645 -28.24138,31.445845 -41.70313,38.808595 0,0 5.80411,6.22782 10.35547,9.83594 21.4747,-9.83532 36.20117,-28.92969 36.20117,-28.92969 h 29.63477 c -1.91396,10.20163 -10.81634,22.87679 -23.28907,30.52344 -4.9726,-4.92682 -9.79912,-9.77207 -15.6875,-15.04688 l -9.44921,8.22461 c 5.17407,4.78946 9.76168,9.71525 13.64453,13.78125 -18.91971,8.17779 -44.14063,9.33203 -44.14063,9.33203 l 5.23047,11.52149 c 0,0 74.07473,-1.87864 89.05664,-58.83008 l 0.10156,-12.363283 h -36.5664 c 2.37183,-3.168548 3.36133,-6.703125 3.36133,-6.703125 l -13.21485,-2.22461 c 2.51836,-1.382805 4.92578,-2.847883 7.2793,-4.322265 15.79695,-10.119207 26.91531,-22.079394 33.84766,-35.513672 l 0.16406,-0.310547 V 29.189453 h -46.51172 c 1.47438,-2.353517 2.83915,-4.798439 4.09375,-7.234375 l 0.67773,-1.328125 z M 436.50977,17.138672 V 44.335938 H 381.14258 V 120.4082 h 55.36719 v 41.29297 h 14.99218 V 120.4082 h 55.92578 V 44.335938 H 451.50195 V 17.138672 Z m 510.62304,0.300781 V 18.75 c 0.0275,9.661324 -8.3e-4,19.039133 -0.10156,28.160156 h -18.74414 v 13.625 h 18.54297 v 18.105469 c 0,27.546225 -4.72487,41.612575 -15.4668,57.958985 -6.39204,9.97268 -18.12305,16.75 -18.12305,16.75 l 10.7793,10.4668 c 0,0 36.7207,-19.23114 36.7207,-79.580082 l 0.28516,-23.701172 h 24.58789 c -0.27473,24.67072 -1.17188,48.948674 -2.55469,72.181644 -0.22894,7.27117 -3.32549,10.65039 -9.69921,10.65039 -5.36639,-0.10989 -16.17188,-0.13672 -16.17188,-0.13672 0.83334,4.34073 2.44531,14.45898 2.44531,14.45898 h 17.31641 c 12.24379,-0.20146 19.03914,-7.51923 20.1289,-21.84179 1.2546,-21.97837 2.21626,-51.501138 2.88477,-87.527348 L 1000,46.910156 h -38.70898 c 0.174,-9.276702 0.29284,-18.682851 0.32031,-28.115234 V 17.439453 Z M 541.71484,22.339844 V 158.01953 l 128.39844,-0.0742 V 22.339844 Z m 210.18555,0.667968 c 0,0 -12.52729,3.178015 -25.41211,4.917969 -16.5204,2.22531 -26.38281,3.195313 -26.38281,3.195313 L 701.57031,44.125 c 0,0 10.98938,-1.162815 19.47852,-2.673828 v 19.78125 H 698.3457 v 12.902344 h 20.74219 c -5.60448,17.298807 -14.11991,34.057594 -23.36914,47.281254 0,0 9.69675,10.47586 9.80664,10.34765 5.925,-8.35178 11.14608,-17.73735 15.52344,-27.90234 V 161.6543 H 734.3457 V 98.255859 c 4.92682,4.478091 9.4591,8.690861 13.72657,12.683591 l 0.9707,0.99024 9.41406,-10.52344 -0.9707,-0.87891 C 751.26828,95.041909 734.3457,81.296875 734.3457,81.296875 v -7.162109 h 19.95508 V 61.232422 H 734.3457 V 40.408203 c 14.10279,-2.316887 20.32032,-4.195312 20.32032,-4.195312 z m 114.58984,7.802735 v 13.296875 h 60.35743 V 30.810547 Z M 555.45898,35.197266 H 656.39453 V 145.0332 H 555.45898 Z M 565.45117,46.625 v 12.445312 h 33.63477 v 18.902344 h -30.87891 v 12.425782 h 30.87891 v 29.855472 h -35.88867 v 12.46289 h 85.25781 V 120.25391 H 612.38281 V 90.398438 h 30.90821 V 77.972656 H 612.38281 V 59.070312 h 33.78321 V 46.625 Z M 395.58008,57.632812 h 40.92969 v 49.423828 h -40.92969 z m 55.91797,0 h 41.43359 v 49.423828 h -41.43359 z m 409.0039,10.861329 v 13.341797 h 23.7168 c -1.41944,7.573379 -9.69655,32.703642 -20.92383,53.216792 -1.39197,2.53667 -3.33398,4.03711 -3.33398,4.03711 l 3.96484,9.56055 c 0,0 38.72781,-10.45791 50.36719,-12.20703 l 1.39258,3.49023 13.62695,-5.16601 C 925.01756,124.16302 918.4151,109.228 913.48828,98.541016 l -0.56836,-1.21875 -12.32617,5.531254 0.55859,1.24609 c 3.26012,7.26201 6.20931,13.85413 9.18555,21.33594 l -31.77734,7.50976 c 7.01476,-11.46539 15.77904,-37.29963 20.54101,-51.109372 h 33.49805 V 68.494141 Z m -233.88672,23.048828 -10.19335,7.693359 1.03515,1.072262 c 4.16673,4.36821 9.45038,10.78629 13.91016,16.77539 l 0.85156,1.08985 10.63281,-8.09571 L 642,108.99805 c -3.77295,-4.81693 -9.37852,-11.189522 -14.58008,-16.574222 z" />
|
||||
<path
|
||||
id="path18"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="m 470.48633,187.40625 v 86.41211 h 11.19922 v -40.01953 c 1.9231,-2.72899 4.25907,-4.95345 6.89648,-6.54688 2.69235,-1.60259 5.54794,-2.4375 8.50586,-2.4375 3.49823,0 5.91673,0.9802 7.42773,2.97657 1.52933,2.00552 2.30665,5.19372 2.30665,9.40625 v 36.62109 h 11.18164 v -38.0957 c 0,-4.18505 -0.57708,-7.63713 -1.76758,-10.32032 -1.19965,-2.7015 -2.76439,-4.84493 -4.6875,-6.39257 -1.89564,-1.52933 -3.97465,-2.58297 -6.18164,-3.21485 -4.31326,-1.14471 -8.78321,-1.14419 -12.51953,-0.10937 -1.78574,0.45788 -3.52389,1.14456 -5.08985,1.96875 -1.57512,0.78755 -3.04066,1.82269 -4.35937,2.97656 -0.59524,0.57693 -1.20922,1.11643 -1.71289,1.69336 v -34.91797 z m 375.20703,0 v 86.41211 h 8.68164 l 1.53906,-5.0918 c 0.20147,0.19231 0.37484,0.34858 0.54883,0.56836 0.80588,0.94324 1.88564,1.81276 3.22266,2.72852 1.33702,0.8883 2.97693,1.64802 4.83593,2.28906 1.93226,0.60441 4.14867,0.95312 6.59375,0.95312 3.26929,0 6.49213,-0.65178 9.55079,-1.8789 3.08612,-1.26376 5.83312,-3.19563 8.23242,-5.75977 2.37184,-2.56414 4.32275,-5.84237 5.74218,-9.73437 1.43775,-3.8279 2.16993,-8.49876 2.16993,-13.7461 0,-3.79126 -0.40142,-7.1788 -1.15235,-10.13671 -0.75093,-2.93046 -1.76736,-5.49505 -3.05859,-7.65625 -1.30955,-2.15205 -2.82073,-4.00158 -4.5332,-5.4668 -1.68501,-1.44692 -3.47949,-2.61879 -5.34766,-3.52539 -1.7949,-0.90661 -3.70007,-1.54728 -5.5957,-1.87695 -1.84985,-0.37546 -3.61739,-0.55079 -5.23828,-0.55079 -3.49823,0 -6.67641,0.60504 -9.45118,1.80469 -2.02383,0.94324 -3.89251,2.10596 -5.56836,3.52539 v -32.85742 z m 83.82031,0 v 86.41211 h 11.19922 v -86.41211 z m -506.96875,4.3125 c -22.80256,0 -41.39258,18.57305 -41.39258,41.38477 0,22.82087 18.59002,41.42968 41.39258,41.42968 16.5021,0 31.41114,-9.79929 37.98633,-24.92773 l 0.6875,-1.52149 h -11.91406 l -0.29297,0.50391 c -5.54039,9.37744 -15.64245,15.19336 -26.4668,15.19336 -16.88671,0 -30.64062,-13.74523 -30.64062,-30.67773 0,-16.88672 13.75391,-30.63282 30.64062,-30.63282 10.77856,0 20.85172,5.72256 26.35547,15.01758 l 0.33984,0.53125 h 11.89454 L 460.47656,216.5 c -6.63013,-15.05518 -21.5028,-24.78125 -37.93164,-24.78125 z m 107.54883,1.72266 v 12.28125 h 11.19922 v -12.28125 z m 375.58203,0 v 12.28125 h 11.20899 v -12.28125 z m -216.13867,0.38476 v 79.99219 h 11.68555 v -38.72852 c 0,-7.4177 -0.21182,-15.5218 -0.57813,-24.17578 2.10626,5.58617 4.20394,10.83294 6.24609,15.75977 l 19.9004,47.14453 h 5.80468 l 19.88282,-47.14453 c 1.97805,-4.64294 4.07437,-9.91779 6.2539,-15.76953 -0.35715,8.76388 -0.54883,16.91437 -0.54883,24.18554 v 38.72852 h 11.64844 V 193.82617 H 754.3457 l -18.58984,44.66992 c -1.63922,3.8279 -3.67296,9.11179 -6.07227,15.79688 -2.17951,-6.06237 -4.19312,-11.35481 -6.04297,-15.78711 l -18.5996,-44.67969 z m 118.29883,20.9336 c -16.61199,0 -30.06446,13.46296 -30.06446,30.05664 0,16.64861 13.45247,30.10937 30.06446,30.10937 16.62114,0 30.08203,-13.46076 30.08203,-30.10937 0,-16.59368 -13.46089,-30.05664 -30.08203,-30.05664 z m -176.45899,0.17382 c -1.58427,0 -3.18794,0.0839 -4.7539,0.23047 -1.60259,0.17399 -3.13196,0.40166 -4.56055,0.68555 -1.41028,0.29305 -2.80131,0.64967 -4.07422,1.08008 -1.33702,0.43042 -2.47265,0.8883 -3.44336,1.38281 l -0.61328,0.27539 v 9.80859 l 1.49219,-0.55859 c 2.38099,-0.87913 4.7888,-1.6024 7.19726,-2.11523 2.39015,-0.49451 4.94559,-0.7793 7.65625,-0.7793 3.5257,0 6.38289,0.59579 8.44336,1.68555 1.98721,1.13555 3.47109,2.55414 4.35938,4.19336 0.42126,0.77839 0.71467,1.72299 0.88867,2.75781 0.20147,1.10807 0.27344,2.40673 0.27344,3.82617 v 2.41016 c -1.64838,-0.55861 -3.4979,-0.97989 -5.4668,-1.38282 -2.48172,-0.45789 -5.23692,-0.72374 -8.1582,-0.76953 -3.14108,0 -6.01763,0.43949 -8.63672,1.26367 -2.58246,0.86998 -4.88037,2.0959 -6.78516,3.7168 -1.89564,1.59344 -3.38046,3.60976 -4.43359,5.88086 -1.02566,2.29858 -1.5918,4.94333 -1.5918,7.85547 0,2.91213 0.49366,5.5691 1.42774,7.9043 0.93409,2.33519 2.30779,4.35957 4.00195,5.98046 1.72164,1.62091 3.79102,2.90164 6.14453,3.74415 2.32605,0.81502 4.92688,1.25585 7.73828,1.25585 2.033,0 4.00185,-0.23877 5.82422,-0.6875 1.81322,-0.4304 3.50778,-1.04473 5.03711,-1.77734 1.53849,-0.75093 2.9118,-1.62903 4.08398,-2.57226 0.59524,-0.4762 1.11682,-0.95214 1.61133,-1.4375 l 0.80664,5.02734 h 9.59766 v -37.99609 c 0,-1.82237 -0.14715,-3.52598 -0.42188,-5.06446 -0.2381,-1.57512 -0.72298,-3.07644 -1.3457,-4.40429 -1.61175,-3.45244 -4.35951,-6.23689 -8.06836,-8.33399 -3.69969,-2.02385 -8.4703,-3.08594 -14.23047,-3.08594 z m 344.125,0.006 c -3.25096,0.0458 -6.54162,0.68095 -9.5957,1.93555 -3.13192,1.28207 -5.93517,3.24237 -8.37109,5.83398 -2.40847,2.5733 -4.35839,5.83434 -5.81446,9.63477 -1.38281,3.81874 -2.125,8.37818 -2.125,13.51563 0,5.21986 0.76916,9.78064 2.23438,13.50781 1.47439,3.77295 3.60027,6.85021 6.22851,9.21289 2.66488,2.3352 5.85148,4.06539 9.46875,5.10937 3.53485,1.04397 7.38066,1.57617 11.4375,1.57617 3.3517,0 6.58445,-0.28438 9.53321,-0.86132 3.00371,-0.57693 5.54878,-1.40099 7.63672,-2.38086 l 0.56835,-0.32032 v -9.67968 L 995.25586,262.5 c -2.39931,0.86998 -4.81Line truncated
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) by Marsupilami -->
|
||||
<svg
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.0"
|
||||
|
||||
|
||||
viewBox="-46.62 -46.62 1680 1647.24"
|
||||
id="svg9522">
|
||||
<defs
|
||||
id="defs9524" />
|
||||
<path
|
||||
d="M 1014.2288,261 C 1014.2288,261 986.22877,230 949.22877,230 C 949.22877,230 868.22877,230 813.22877,405 C 813.22877,405 766.22877,558 766.22877,785 C 766.22877,785 766.22877,999 810.22877,1150 C 810.22877,1150 854.22877,1303 918.22877,1303 C 918.22877,1303 963.22877,1303 1001.2288,1219 C 1001.2288,1219 1039.2288,1135 1055.2288,998 L 1175.2288,968 C 1175.2288,968 1144.2288,1223 1048.2288,1388 C 1048.2288,1388 949.22877,1554 824.22877,1554 C 824.22877,1554 674.22877,1554 568.22877,1329 C 568.22877,1329 462.22877,1104 462.22877,785 C 462.22877,785 462.22877,466 568.22877,239 C 568.22877,239 674.22877,13 824.22877,13 C 824.22877,13 974.22877,13 1081.2288,243 L 1014.2288,261"
|
||||
id="path8564"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 1436.2288,568 C 1436.2288,568 1229.2288,739 899.22877,822 C 899.22877,822 568.22877,904 305.22877,850 C 305.22877,850 42.228768,794 3.2287685,633 C 3.2287685,633 -29.771232,499 115.22877,351 C 115.22877,351 258.22877,204 515.22877,106 L 532.22877,172 C 532.22877,172 382.22877,236 299.22877,317 C 299.22877,317 214.22877,402 233.22877,475 C 233.22877,475 258.22877,568 434.22877,596 C 434.22877,596 609.22877,623 836.22877,567 C 836.22877,567 1061.2288,511 1205.2288,404 C 1205.2288,404 1347.2288,296 1323.2288,202 C 1323.2288,202 1303.2288,121 1159.2288,86 L 1137.2288,-8.5265128e-14 C 1137.2288,-8.5265128e-14 1324.2288,7 1448.2288,69 C 1448.2288,69 1575.2288,131 1601.2288,236 C 1601.2288,236 1642.2288,396 1436.2288,568"
|
||||
id="path8566"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2620.2288,207 L 2620.2288,209 L 2628.2288,212 L 2638.2288,209 L 2643.2288,206 L 2653.2288,207 C 2653.2288,207 2665.2288,210 2675.2288,207 L 2678.2288,207 L 2628.2288,201 L 2620.2288,207"
|
||||
id="path8568"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2307.2288,35 C 2304.2288,38 2300.2288,39 2300.2288,39 L 2313.2288,69 L 2304.2288,69 L 2313.2288,98 C 2319.2288,131 2319.2288,196 2319.2288,196 L 2315.2288,213 L 2310.2288,231 L 2313.2288,264 C 2312.2288,277 2297.2288,292 2297.2288,292 L 2297.2288,308 C 2239.2288,329 2231.2288,340 2231.2288,340 L 2200.2288,349 C 2186.2288,358 2157.2288,369 2157.2288,369 C 2138.2288,388 2118.2288,391 2118.2288,391 L 2115.2288,393 L 2112.2288,393 L 2112.2288,394 L 2104.2288,394 C 2115.2288,372 2104.2288,349 2104.2288,349 L 2097.2288,337 L 2089.2288,307 C 2082.2288,283 2077.2288,281 2077.2288,281 L 2068.2288,295 L 2061.2288,313 L 2055.2288,313 L 2042.2288,346 L 2030.2288,376 L 2014.2288,420 C 2015.2288,425 1996.2288,464 1996.2288,464 L 1988.2288,472 C 1988.2288,500 1976.2288,508 1976.2288,508 L 1968.2288,528 C 1962.2288,543 1961.2288,541 1961.2288,541 C 1962.2288,549 1956.2288,591 1956.2288,591 C 1953.2288,633 1967.2288,641 1967.2288,641 C 1973.2288,650 1970.2288,653 1970.2288,653 L 1985.2288,683 L 1990.2288,682 L 1996.2288,692 C 2006.2288,715 2027.2288,709 2027.2288,709 L 2065.2288,698 L 2073.2288,732 C 2077.2288,754 2085.2288,760 2085.2288,760 L 2114.2288,766 L 2144.2288,769 L 2173.2288,756 L 2238.2288,741 C 2280.2288,732 2301.2288,721 2301.2288,721 C 2318.2288,753 2315.2288,779 2315.2288,779 L 2312.2288,831 L 2318.2288,853 L 2322.2288,874 L 2324.2288,971 L 2334.2288,1001 L 2330.2288,1013 L 2330.2288,1049 L 2340.2288,1079 L 2343.2288,1113 L 2349.2288,1113 L 2346.2288,1082 L 2357.2288,1084 L 2359.2288,1099 L 2362.2288,1098 L 2360.2288,1079 C 2349.2288,1075 2349.2288,1055 2349.2288,1055 C 2349.2288,1037 2357.2288,1025 2357.2288,1025 C 2363.2288,1027 2372.2288,1060 2372.2288,1060 C 2380.2288,1085 2381.2288,1101 2381.2288,1101 L 2402.2288,1101 L 2402.2288,1110 L 2396.2288,1105 C 2386.2288,1111 2384.2288,1143 2384.2288,1143 C 2383.2288,1175 2389.2288,1176 2389.2288,1176 C 2392.2288,1176 2408.2288,1164 2408.2288,1164 L 2404.2288,1170 L 2404.2288,1173 L 2410.2288,1173 L 2413.2288,1190 C 2420.2288,1190 2420.2288,1149 2420.2288,1149 L 2424.2288,1149 L 2431.2288,1155 L 2434.2288,1152 L 2434.2288,1113 L 2442.2288,1120 L 2452.2288,1111 L 2458.2288,1098 C 2460.2288,1089 2455.2288,1063 2455.2288,1063 C 2463.2288,1023 2464.2288,1002 2464.2288,1002 L 2464.2288,980 L 2493.2288,915 C 2501.2288,890 2502.2288,850 2502.2288,850 L 2505.2288,783 L 2517.2288,766 C 2528.2288,756 2529.2288,750 2529.2288,750 L 2552.2288,685 C 2570.2288,638 2582.2288,633 2582.2288,633 L 2640.2288,620 C 2678.2288,608 2675.2288,588 2675.2288,588 C 2694.2288,588 2702.2288,505 2702.2288,505 L 2718.2288,423 L 2732.2288,396 L 2738.2288,333 L 2749.2288,271 C 2752.2288,242 2735.2288,228 2735.2288,228 C 2724.2288,218 2693.2288,210 2693.2288,210 L 2690.2288,212 C 2697.2288,216 2712.2288,221 2712.2288,221 C 2726.2288,228 2729.2288,239 2729.2288,239 C 2733.2288,255 2723.2288,281 2723.2288,281 C 2714.2288,304 2699.2288,317 2699.2288,317 L 2700.2288,328 C 2696.2288,342 2673.2288,369 2673.2288,369 L 2641.2288,413 L 2613.2288,431 C 2575.2288,432 2563.2288,444 2563.2288,444 L 2558.2288,440 C 2552.2288,431 2552.2288,385 2552.2288,385 L 2548.2288,349 C 2554.2288,340 2554.2288,334 2554.2288,334 L 2554.2288,314 C 2564.2288,302 2623.2288,280 2623.2288,280 C 2676.2288,258 2682.2288,237 2682.2288,237 L 2685.2288,240 C 2684.2288,249 2693.2288,251 2693.2288,251 C 2693.2288,236 2703.2288,236 2703.2288,236 L 2724.2288,240 L 2724.2288,237 L 2690.2288,222 C 2690.2288,230 2684.2288,230 2684.2288,230 L 2671.2288,228 L 2670.2288,234 L 2675.2288,237 L 2668.2288,248 L 2665.2288,248 C 2665.2288,243 2646.2288,243 2646.2288,243 L 2652.2288,221 C 2635.2288,213 2634.2288,228 2634.2288,228 L 2632.2288,228 L 2629.2288,222 L 2626.2288,222 L 2625.2288,227 L 2613.2288,227 L 2605.2288,236 L 2596.2288,233 L 2602.2288,227 L 2596.2288,215 C 2608.2288,215 2614.2288,201 2614.2288,201 L 2540.2288,206 C 2540.2288,216 2561.2288,213 2561.2288,213 C 2584.2288,210 2587.2288,218 2587.2288,218 L 2564.2288,216 L 2543.2288,219 L 2540.2288,228 L 2534.2288,224 L 2528.2288,219 L 2513.2288,213 C 2504.2288,219 2499.2288,218 2499.2288,218 L 2489.2288,212 L 2479.2288,202 C 2467.2288,193 2414.2288,103 2414.2288,103 C 2375.2288,38 2307.2288,35 2307.2288,35 z M 2297.2288,402 L 2309.2288,450 L 2316.2288,482 C 2322.2288,502 2319.2288,515 2319.2288,515 L 2300.2288,543 L 2277.2288,573 C 2250.2288,590 2230.2288,596 2230.2288,596 L 2179.2288,603 C 2154.2288,606 2126.2288,632 2126.2288,632 C 2098.2288,656 2088.2288,655 2088.2288,655 C 2083.2288,658 2077.2288,659 2077.2288,659 C 2071.2288,644 2083.2288,635 2083.2288,635 C 2079.2288,629 2086.2288,612 2086.2288,612 L 2101.2288,594 L 2112.2288,573 L 2118.2288,547 C 2129.2288,540 2127.2288,532 2127.2288,532 L 2179.2288,472 L 2242.2288,444 C 2287.2288,425 2297.2288,402 2297.2288,402 z"
|
||||
id="path8570"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3320.2288,201 L 3359.2288,186 L 3399.2288,175 L 3435.2288,171 L 3450.2288,165 L 3491.2288,163 L 3532.2288,165 L 3548.2288,166 L 3565.2288,172 L 3577.2288,190 L 3591.2288,216 C 3591.2288,216 3583.2288,255 3591.2288,263 C 3591.2288,263 3585.2288,275 3582.2288,352 L 3574.2288,435 L 3568.2288,494 L 3573.2288,577 C 3573.2288,577 3573.2288,593 3565.2288,620 C 3565.2288,620 3574.2288,662 3565.2288,671 C 3565.2288,671 3585.2288,797 3582.2288,804 L 3571.2288,831 L 3562.2288,857 L 3561.2288,872 L 3565.2288,877 L 3562.2288,895 L 3564.2288,912 C 3564.2288,912 3562.2288,925 3556.2288,925 L 3541.2288,915 L 3529.2288,901 L 3529.2288,900 C 3529.2288,900 3503.2288,878 3491.2288,884 C 3491.2288,884 3491.2288,921 3515.2288,958 L 3520.2288,961 C 3520.2288,961 3523.2288,978 3551.2288,1022 L 3554.2288,1028 L 3577.2288,1096 C 3577.2288,1096 3594.2288,1120 3660.2288,1150 L 3666.2288,1149 L 3666.2288,1144 C 3666.2288,1144 3672.2288,1144 3686.2288,1128 C 3686.2288,1128 3704.2288,1105 3706.2288,1079 L 3706.2288,1028 L 3712.2288,992 L 3712.2288,955 L 3703.2288,922 C 3703.2288,922 3698.2288,896 3709.2288,892 C 3709.2288,892 3709.2288,839 3704.2288,836 C 3704.2288,836 3713.2288,795 3707.2288,748 L 3709.2288,711 L 3709.2288,673 L 3703.2288,590 L 3701.2288,505 L 3698.2288,429 L 3689.2288,357 C 3689.2288,357 3692.2288,336 3697.2288,328 C 3697.2288,328 3694.2288,316 3698.2288,278 C 3698.2288,278 3685.2288,277 3692.2288,230 L 3701.2288,172 C 3701.2288,172 3698.2288,157 3672.2288,124 L 3635.2288,83 C 3635.2288,83 3612.2288,71 3600.2288,74 L 3520.2288,86 C 3520.2288,86 3464.2288,92 3449.2288,109 L 3429.2288,107 L 3408.2288,112 L 3405.2288,115 L 3367.2288,119 L 3337.2288,125 L 3326.2288,125 L 3287.2288,139 L 3252.2288,156 L 3217.2288,169 C 3217.2288,169 3186.2288,195 3170.2288,192 C 3170.2288,192 3102.2288,209 3075.2288,225 C 3075.2288,225 3075.2288,227 3093.2288,231 C 3093.2288,231 3111.2288,236 3113.2288,246 L 3133.2288,252 C 3133.2288,252 3181.2288,260 3208.2288,242 C 3208.2288,242 3214.2288,245 3248.2288,230 C 3248.2288,230 3255.2288,221 3272.2288,221 L 3293.2288,209 L 3320.2288,201"
|
||||
id="path8572"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2443.2288,1125 L 2442.2288,1166 C 2442.2288,1166 2449.2288,1166 2449.2288,1147 C 2449.2288,1147 2451.2288,1129 2443.2288,1125"
|
||||
id="path8576"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2355.2288,1147 L 2360.2288,1164 L 2362.2288,1164 L 2366.2288,1155 L 2366.2288,1137 L 2363.2288,1137 L 2362.2288,1138 L 2365.2288,1128 L 2351.2288,1120 C 2351.2288,1120 2348.2288,1129 2355.2288,1147"
|
||||
id="path8578"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2375.2288,1176 L 2377.2288,1173 C 2377.2288,1173 2380.2288,1143 2374.2288,1137 C 2374.2288,1137 2366.2288,1144 2372.2288,1176 L 2375.2288,1176"
|
||||
id="path8580"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2354.2288,1246 L 2354.2288,1358 L 2448.2288,1358 L 2448.2288,1246 L 2529.2288,1246 L 2529.2288,1515 L 2448.2288,1515 L 2448.2288,1400 L 2354.2288,1400 L 2354.2288,1515 L 2272.2288,1515 L 2272.2288,1246 L 2354.2288,1246"
|
||||
id="path8582"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2079.2288,1309 C 2079.2288,1309 2055.2288,1333 2055.2288,1380 L 2055.2288,1383 C 2055.2288,1383 2055.2288,1432 2077.2288,1456 C 2077.2288,1456 2103.2288,1479 2141.2288,1479 L 2188.2288,1474 L 2218.2288,1467 L 2218.2288,1507 L 2188.2288,1516 L 2136.2288,1519 C 2136.2288,1519 2062.2288,1519 2015.2288,1486 C 2015.2288,1486 1970.2288,1453 1970.2288,1388 L 1970.2288,1376 C 1970.2288,1376 1970.2288,1315 2017.2288,1279 C 2017.2288,1279 2064.2288,1241 2135.2288,1241 L 2186.2288,1244 L 2218.2288,1252 L 2218.2288,1294 L 2189.2288,1287 L 2148.2288,1284 C 2148.2288,1284 2104.2288,1284 2079.2288,1309"
|
||||
id="path8584"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3489.2288,541 C 3489.2288,541 3491.2288,558 3471.2288,580 L 3465.2288,588 L 3464.2288,591 L 3458.2288,603 L 3455.2288,617 L 3446.2288,623 L 3443.2288,629 L 3446.2288,630 C 3446.2288,630 3456.2288,633 3473.2288,624 L 3489.2288,620 L 3508.2288,618 L 3521.2288,617 L 3529.2288,615 C 3529.2288,615 3547.2288,621 3561.2288,600 L 3564.2288,573 L 3561.2288,558 L 3556.2288,547 L 3550.2288,544 L 3541.2288,540 L 3520.2288,538 L 3517.2288,535 L 3497.2288,526 L 3482.2288,520 L 3489.2288,541"
|
||||
id="path8586"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3420.2288,242 L 3394.2288,249 L 3365.2288,255 L 3310.2288,267 L 3282.2288,283 L 3258.2288,289 C 3258.2288,289 3254.2288,292 3248.2288,293 L 3237.2288,295 L 3226.2288,298 L 3186.2288,316 L 3166.2288,328 L 3152.2288,331 L 3148.2288,334 L 3146.2288,336 L 3139.2288,345 L 3125.2288,352 L 3108.2288,364 C 3108.2288,364 3077.2288,364 3083.2288,387 L 3089.2288,387 C 3089.2288,387 3089.2288,391 3127.2288,407 L 3128.2288,425 C 3128.2288,425 3131.2288,438 3140.2288,444 L 3180.2288,444 L 3193.2288,446 L 3214.2288,446 C 3214.2288,446 3220.2288,446 3220.2288,450 L 3223.2288,470 L 3225.2288,485 L 3223.2288,508 L 3216.2288,523 L 3199.2288,526 L 3186.2288,534 L 3148.2288,549 L 3133.2288,561 L 3110.2288,576 L 3098.2288,588 L 3099.2288,593 L 3099.2288,597 L 3087.2288,608 L 3089.2288,620 L 3093.2288,623 L 3107.2288,636 L 3127.2288,639 L 3136.2288,655 L 3148.2288,667 L 3163.2288,670 L 3175.2288,668 L 3173.2288,677 C 3173.2288,677 3169.2288,686 3173.2288,686 L 3167.2288,706 L 3160.2288,720 L 3157.2288,727 L 3139.2288,741 L 3130.2288,753 L 3121.2288,769 L 3121.2288,776 L 3114.2288,791 L 3108.2288,800 L 3108.2288,812 L 3116.2288,834 L 3125.2288,848 C 3125.2288,848 3131.2288,856 3133.2288,854 C 3133.2288,854 3133.2288,856 3143.2288,860 C 3143.2288,860 3164.2288,863 3176.2288,860 L 3217.2288,851 L 3240.2288,839 L 3281.2288,825 L 3290.2288,818 C 3290.2288,818 3296.2288,818 3320.2288,800 C 3320.2288,800 3325.2288,803 3337.2288,791 C 3337.2288,791 3337.2288,792 3353.2288,783 L 3367.2288,779 C 3367.2288,779 3375.2288,776 3382.2288,771 L 3402.2288,765 L 3429.2288,747 L 3449.2288,730 C 3449.2288,730 3459.2288,724 3462.2288,712 L 3467.2288,703 L 3470.2288,698 L 3458.2288,698 C 3458.2288,698 3450.2288,698 3455.2288,686 L 3462.2288,673 L 3438.2288,671 C 3438.2288,671 3423.2288,671 3423.2288,665 L 3381.2288,668 L 3385.2288,652 L 3387.2288,639 L 3393.2288,623 C 3393.2288,623 3388.2288,623 3388.2288,614 L 3394.2288,609 L 3405.2288,608 L 3429.2288,574 L 3455.2288,547 L 3456.2288,540 L 3462.2288,528 L 3462.2288,515 L 3459.2288,500 L 3443.2288,484 L 3414.2288,464 L 3402.2288,458 L 3397.2288,453 L 3387.2288,446 L 3390.2288,441 C 3390.2288,441 3390.2288,428 3397.2288,413 L 3427.2288,381 C 3427.2288,381 3435.2288,381 3449.2288,352 L 3459.2288,339 L 3462.2288,326 L 3461.2288,313 L 3464.2288,304 L 3462.2288,290 L 3447.2288,264 L 3440.2288,254 C 3440.2288,254 3440.2288,242 3420.2288,242"
|
||||
id="path8588"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3172.2288,925 L 3149.2288,931 L 3114.2288,931 L 3098.2288,934 L 3080.2288,933 L 3099.2288,975 C 3099.2288,975 3118.2288,1010 3124.2288,1011 L 3142.2288,1016 L 3170.2288,1008 C 3170.2288,1008 3190.2288,1004 3193.2288,998 L 3199.2288,998 C 3199.2288,998 3225.2288,992 3231.2288,983 L 3263.2288,975 L 3293.2288,961 L 3307.2288,957 L 3329.2288,946 L 3353.2288,931 L 3415.2288,887 C 3415.2288,887 3440.2288,871 3497.2288,844 L 3511.2288,831 L 3511.2288,828 L 3502.2288,828 C 3502.2288,828 3494.2288,834 3462.2288,842 L 3432.2288,854 L 3423.2288,856 L 3396.2288,866 L 3365.2288,871 L 3323.2288,889 L 3302.2288,893 L 3281.2288,903 L 3269.2288,904 L 3240.2288,910 C 3240.2288,910 3216.2288,922 3210.2288,924 L 3196.2288,921 L 3172.2288,925"
|
||||
id="path8590"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3024.2288,283 L 3015.2288,310 C 3015.2288,310 3010.2288,325 3012.2288,342 L 3015.2288,354 L 3015.2288,396 L 3018.2288,422 L 3013.2288,441 L 3012.2288,473 L 3006.2288,487 L 2997.2288,525 L 3000.2288,532 L 3000.2288,564 L 2995.2288,582 L 2995.2288,596 L 2992.2288,624 L 2987.2288,644 L 2983.2288,707 L 2981.2288,707 L 2978.2288,726 L 2971.2288,742 C 2971.2288,742 2968.2288,765 2974.2288,779 L 2975.2288,798 L 2977.2288,824 C 2977.2288,824 2978.2288,838 2972.2288,845 L 2969.2288,869 C 2969.2288,869 2966.2288,924 2998.2288,951 C 2998.2288,951 3009.2288,961 3018.2288,958 L 3025.2288,949 C 3025.2288,949 3037.2288,933 3037.2288,904 L 3033.2288,856 L 3030.2288,842 L 3036.2288,797 C 3036.2288,797 3036.2288,777 3039.2288,769 C 3039.2288,769 3024.2288,759 3034.2288,711 L 3036.2288,683 L 3037.2288,665 C 3037.2288,665 3040.2288,650 3042.2288,650 L 3045.2288,635 L 3048.2288,624 L 3063.2288,553 L 3077.2288,512 L 3077.2288,505 L 3071.2288,494 L 3071.2288,478 L 3075.2288,467 L 3080.2288,443 L 3078.2288,443 L 3080.2288,425 L 3078.2288,407 C 3078.2288,407 3071.2288,407 3071.2288,358 L 3062.2288,329 L 3054.2288,320 L 3045.2288,302 L 3036.2288,274 L 3028.2288,267 L 3024.2288,283"
|
||||
id="path8592"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2661.2288,233 L 2662.2288,227 L 2658.2288,219 L 2658.2288,231 L 2661.2288,233"
|
||||
id="path8594"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2678.2288,224 L 2664.2288,219 L 2664.2288,224 L 2678.2288,224"
|
||||
id="path8596"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3051.2288,1246 L 3051.2288,1515 L 3016.2288,1515 L 2835.2288,1346 L 2835.2288,1515 L 2776.2288,1515 L 2776.2288,1246 L 2820.2288,1246 L 2994.2288,1406 L 2994.2288,1246 L 3051.2288,1246"
|
||||
id="path8598"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2696.2288,1246 L 2696.2288,1515 L 2614.2288,1515 L 2614.2288,1246 L 2696.2288,1246"
|
||||
id="path8600"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3205.2288,1246 L 3083.2288,1515 L 3149.2288,1515 L 3176.2288,1451 L 3297.2288,1451 L 3323.2288,1515 L 3408.2288,1515 L 3281.2288,1246 L 3205.2288,1246 z M 3235.2288,1312 L 3281.2288,1412 L 3193.2288,1412 L 3235.2288,1312 z"
|
||||
id="path8602"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3535.2288,1246 L 3777.2288,1246 L 3777.2288,1285 L 3698.2288,1285 L 3698.2288,1515 L 3616.2288,1515 L 3616.2288,1285 L 3535.2288,1285 L 3535.2288,1246"
|
||||
id="path8604"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5302.2288,183 L 5313.2288,169 L 5324.2288,171 L 5331.2288,169 L 5364.2288,166 C 5364.2288,166 5366.2288,172 5334.2288,172 L 5316.2288,180 L 5327.2288,186 L 5361.2288,190 C 5361.2288,190 5372.2288,192 5387.2288,204 L 5407.2288,225 L 5392.2288,242 L 5379.2288,248 L 5366.2288,249 L 5314.2288,260 L 5274.2288,263 L 5242.2288,272 L 5175.2288,280 L 5147.2288,289 L 5130.2288,299 L 5127.2288,299 L 5124.2288,293 L 5116.2288,301 C 5116.2288,301 5113.2288,307 5113.2288,316 C 5113.2288,316 5113.2288,329 5121.2288,334 L 5139.2288,339 C 5139.2288,339 5151.2288,345 5157.2288,345 L 5195.2288,334 L 5236.2288,326 L 5274.2288,316 L 5336.2288,307 L 5532.2288,302 L 5582.2288,295 L 5597.2288,298 L 5611.2288,299 C 5611.2288,299 5629.2288,290 5635.2288,293 L 5637.2288,283 L 5637.2288,263 L 5649.2288,255 C 5649.2288,255 5673.2288,239 5664.2288,227 L 5632.2288,218 L 5596.2288,219 L 5544.2288,219 C 5544.2288,219 5525.2288,219 5526.2288,209 L 5528.2288,195 C 5528.2288,195 5522.2288,177 5525.2288,171 L 5523.2288,140 L 5503.2288,125 L 5491.2288,124 L 5478.2288,113 L 5422.2288,89 L 5401.2288,83 L 5389.2288,86 L 5370.2288,118 L 5369.2288,121 L 5358.2288,118 L 5345.2288,127 L 5346.2288,128 L 5339.2288,133 L 5316.2288,150 C 5316.2288,150 5302.2288,163 5313.2288,160 L 5316.2288,160 L 5310.2288,165 L 5281.2288,175 L 5278.2288,184 L 5286.2288,187 L 5302.2288,183"
|
||||
id="path8608"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4244.2288,171 L 4241.2288,204 C 4241.2288,204 4224.2288,231 4220.2288,245 C 4220.2288,245 4206.2288,352 4197.2288,363 L 4180.2288,378 L 4161.2288,387 L 4140.2288,401 L 4088.2288,438 L 4087.2288,440 L 4108.2288,443 L 4120.2288,456 L 4144.2288,449 L 4170.2288,446 L 4171.2288,466 L 4176.2288,473 L 4159.2288,500 C 4159.2288,500 4159.2288,523 4078.2288,577 C 4078.2288,577 4075.2288,568 4078.2288,537 L 4084.2288,496 L 4082.2288,479 L 4070.2288,458 C 4070.2288,458 4056.2288,440 4055.2288,432 C 4055.2288,432 4038.2288,429 4037.2288,414 L 4028.2288,396 C 4028.2288,396 4025.2288,396 4020.2288,414 L 4011.2288,435 L 4008.2288,446 L 4005.2288,447 C 4005.2288,447 4005.2288,456 3993.2288,472 L 3981.2288,493 C 3981.2288,493 3978.2288,506 3981.2288,565 L 3976.2288,624 L 3967.2288,682 L 3955.2288,721 C 3955.2288,721 3946.2288,741 3955.2288,763 C 3955.2288,763 3960.2288,772 3979.2288,783 C 3979.2288,783 3998.2288,792 4008.2288,794 L 4020.2288,807 C 4020.2288,807 4026.2288,822 4031.2288,824 L 4061.2288,845 L 4090.2288,850 L 4114.2288,859 L 4131.2288,928 C 4131.2288,928 4146.2288,987 4165.2288,993 L 4167.2288,993 C 4167.2288,993 4168.2288,999 4179.2288,1007 L 4194.2288,1016 C 4194.2288,1016 4221.2288,1034 4242.2288,1040 L 4295.2288,1048 L 4341.2288,1049 L 4394.2288,1054 C 4394.2288,1054 4439.2288,1049 4444.2288,1040 L 4490.2288,1034 C 4490.2288,1034 4540.2288,1022 4537.2288,1017 L 4543.2288,1016 L 4633.2288,993 L 4655.2288,984 L 4651.2288,984 L 4655.2288,975 L 4658.2288,975 L 4657.2288,969 L 4660.2288,969 L 4663.2288,972 L 4664.2288,969 L 4663.2288,966 L 4667.2288,960 L 4663.2288,961 L 4670.2288,948 L 4681.2288,937 L 4682.2288,937 L 4682.2288,943 L 4690.2288,933 L 4692.2288,933 L 4692.2288,934 L 4688.2288,937 L 4690.2288,940 C 4690.2288,940 4688.2288,943 4692.2288,942 L 4693.2288,936 L 4698.2288,933 L 4699.2288,934 L 4704.2288,928 L 4707.2288,927 L 4707.2288,918 L 4701.2288,915 L 4698.2288,919 L 4698.2288,912 L 4695.2288,910 L 4695.2288,913 L 4692.2288,912 L 4692.2288,907 L 4688.2288,903 L 4695.2288,898 L 4693.2288,895 L 4685.2288,903 L 4688.2288,893 L 4693.2288,893 L 4695.2288,890 L 4682.2288,889 L 4678.2288,892 L 4676.2288,890 L 4678.2288,883 L 4669.2288,881 L 4675.2288,878 L 4672.2288,875 L 4672.2288,872 L 4676.2288,874 L 4676.2288,872 L 4670.2288,866 L 4669.2288,868 L 4664.2288,862 L 4663.2288,860 L 4657.2288,862 L 4655.2288,859 L 4655.2288,863 L 4660.2288,863 L 4660.2288,872 L 4658.2288,872 L 4652.2288,860 L 4649.2288,859 L 4646.2288,860 L 4652.2288,856 L 4654.2288,856 L 4643.2288,854 C 4643.2288,854 4637.2288,854 4636.2288,859 L 4625.2288,865 L 4608.2288,877 L 4590.2288,881 L 4572.2288,889 L 4513.2288,900 L 4463.2288,915 L 4445.2288,918 L 4348.2288,928 L 4323.2288,919 C 4323.2288,919 4318.2288,919 4315.2288,915 L 4312.2288,904 C 4312.2288,904 4292.2288,863 4295.2288,853 L 4323.2288,816 L 4338.2288,803 C 4338.2288,803 4348.2288,795 4365.2288,797 L 4382.2288,794 L 4403.2288,791 L 4416.2288,783 L 4433.2288,780 L 4450.2288,776 L 4466.2288,772 L 4534.2288,763 L 4543.2288,756 L 4551.2288,744 L 4565.2288,724 C 4565.2288,724 4580.2288,712 4581.2288,700 L 4581.2288,685 L 4586.2288,667 L 4571.2288,632 L 4555.2288,599 L 4584.2288,525 L 4589.2288,506 C 4589.2288,506 4593.2288,491 4598.2288,491 L 4602.2288,470 L 4607.2288,447 C 4607.2288,447 4613.2288,434 4611.2288,426 L 4608.2288,407 L 4611.2288,394 L 4607.2288,379 C 4607.2288,379 4601.2288,370 4601.2288,364 L 4586.2288,342 L 4574.2288,308 C 4574.2288,308 4568.2288,293 4543.2288,283 L 4539.2288,286 L 4516.2288,277 C 4516.2288,277 4501.2288,267 4487.2288,271 L 4456.2288,266 L 4401.2288,292 L 4341.2288,325 L 4323.2288,325 L 4335.2288,248 C 4335.2288,248 4338.2288,207 4295.2288,187 L 4271.2288,175 L 4244.2288,171 z M 4430.2288,354 L 4475.2288,364 L 4474.2288,370 L 4483.2288,382 L 4483.2288,391 C 4483.2288,391 4483.2288,399 4471.2288,411 L 4457.2288,411 L 4454.2288,411 C 4454.2288,411 4407.2288,444 4379.2288,450 L 4356.2288,453 L 4345.2288,453 C 4345.2288,453 4333.2288,456 4320.2288,453 C 4320.2288,453 4301.2288,450 4298.2288,440 L 4301.2288,419 C 4301.2288,419 4306.2288,404 4310.2288,402 C 4310.2288,402 4310.2288,394 4324.2288,388 L 4338.2288,382 C 4338.2288,382 4350.2288,376 4372.2288,370 L 4389.2288,361 L 4412.2288,355 L 4430.2288,354 z M 4439.2288,526 L 4442.2288,529 C 4442.2288,529 4447.2288,534 4444.2288,540 L 4434.2288,588 L 4427.2288,600 C 4427.2288,600 4419.2288,611 4379.2288,629 C 4379.2288,629 4338.2288,656 4315.2288,639 L 4307.2288,618 L 4312.2288,602 C 4312.2288,602 4310.2288,588 4314.2288,580 L 4327.2288,565 L 4360.2288,547 L 4403.2288,532 L 4439.2288,526 z M 4114.2288,630 L 4134.2288,638 C 4134.2288,638 4143.2288,647 4137.2288,674 L 4125.2288,711 L 4123.2288,718 L 4114.2288,732 L 4093.2288,736 C 4093.2288,736 4087.2288,736 4067.2288,747 C 4067.2288,747 4058.2288,753 4047.2288,757 L 4040.2288,765 L 4038.2288,763 L 4046.2288,738 L 4043.2288,723 L 4047.2288,709 L 4049.2288,695 L 4061.2288,662 L 4066.2288,647 L 4067.2288,647 C 4067.2288,647 4078.2288,647 4093.2288,6Line truncated
|
||||
id="path8610"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4982.2288,283 L 4982.2288,292 C 4982.2288,292 4970.2288,328 4970.2288,345 L 4950.2288,376 L 4947.2288,387 L 4941.2288,399 L 4924.2288,435 L 4897.2288,494 L 4882.2288,512 L 4859.2288,547 L 4852.2288,570 L 4844.2288,594 L 4846.2288,617 L 4847.2288,652 C 4847.2288,652 4852.2288,655 4855.2288,656 L 4865.2288,671 L 4867.2288,703 L 4865.2288,736 L 4870.2288,729 L 4874.2288,717 L 4874.2288,715 L 4876.2288,715 L 4877.2288,717 L 4874.2288,723 L 4876.2288,726 L 4879.2288,726 L 4884.2288,717 L 4893.2288,689 L 4909.2288,671 L 4918.2288,665 L 4980.2288,605 C 4980.2288,605 4998.2288,599 5009.2288,624 L 5012.2288,647 L 5018.2288,692 L 5018.2288,750 L 5011.2288,783 L 5004.2288,842 L 4995.2288,869 L 4983.2288,939 L 4982.2288,1014 C 4982.2288,1014 4982.2288,1046 5012.2288,1049 L 5026.2288,1033 C 5026.2288,1033 5048.2288,1034 5039.2288,1022 L 5039.2288,1010 L 5051.2288,1001 L 5062.2288,1001 L 5077.2288,977 L 5083.2288,931 L 5092.2288,831 L 5095.2288,797 L 5101.2288,763 L 5112.2288,630 C 5112.2288,630 5107.2288,600 5109.2288,588 L 5118.2288,567 C 5118.2288,567 5122.2288,558 5122.2288,534 L 5119.2288,523 L 5110.2288,515 L 5101.2288,505 C 5101.2288,505 5094.2288,502 5085.2288,497 L 5056.2288,484 L 5038.2288,488 L 5042.2288,470 L 5048.2288,455 L 5050.2288,425 L 5054.2288,410 C 5054.2288,410 5056.2288,390 5044.2288,364 L 5023.2288,322 L 5021.2288,310 L 5011.2288,284 L 4988.2288,264 L 4982.2288,283"
|
||||
id="path8612"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4177.2288,1246 L 4177.2288,1476 L 4312.2288,1476 L 4312.2288,1515 L 4096.2288,1515 L 4096.2288,1246 L 4177.2288,1246"
|
||||
id="path8620"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3896.2288,1285 L 3896.2288,1359 L 4004.2288,1359 L 4004.2288,1400 L 3896.2288,1400 L 3896.2288,1476 L 4044.2288,1476 L 4044.2288,1515 L 3815.2288,1515 L 3815.2288,1246 L 4035.2288,1246 L 4035.2288,1285 L 3896.2288,1285"
|
||||
id="path8622"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4366.2288,1246 L 4587.2288,1246 L 4587.2288,1285 L 4448.2288,1285 L 4448.2288,1359 L 4555.2288,1359 L 4555.2288,1400 L 4448.2288,1400 L 4448.2288,1476 L 4598.2288,1476 L 4598.2288,1515 L 4366.2288,1515 L 4366.2288,1246"
|
||||
id="path8624"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4741.2288,1309 C 4741.2288,1309 4716.2288,1335 4716.2288,1380 L 4716.2288,1383 C 4716.2288,1383 4716.2288,1432 4738.2288,1456 C 4738.2288,1456 4766.2288,1479 4802.2288,1479 L 4849.2288,1474 L 4879.2288,1467 L 4879.2288,1507 L 4849.2288,1516 L 4797.2288,1519 C 4797.2288,1519 4722.2288,1519 4678.2288,1486 C 4678.2288,1486 4631.2288,1453 4631.2288,1388 L 4631.2288,1376 C 4631.2288,1376 4631.2288,1315 4678.2288,1279 C 4678.2288,1279 4725.2288,1241 4797.2288,1241 L 4847.2288,1244 L 4879.2288,1252 L 4879.2288,1294 L 4852.2288,1287 L 4811.2288,1284 C 4811.2288,1284 4767.2288,1284 4741.2288,1309"
|
||||
id="path8626"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5066.2288,1241 C 5066.2288,1241 4992.2288,1241 4955.2288,1279 C 4955.2288,1279 4915.2288,1317 4915.2288,1374 L 4915.2288,1389 C 4915.2288,1389 4915.2288,1451 4958.2288,1486 C 4958.2288,1486 4997.2288,1519 5065.2288,1519 C 5065.2288,1519 5133.2288,1519 5172.2288,1486 C 5172.2288,1486 5215.2288,1451 5215.2288,1389 L 5215.2288,1374 C 5215.2288,1374 5215.2288,1317 5175.2288,1279 C 5175.2288,1279 5139.2288,1241 5066.2288,1241 z M 5065.2288,1281 C 5065.2288,1281 5109.2288,1281 5119.2288,1303 C 5119.2288,1303 5131.2288,1330 5131.2288,1377 L 5131.2288,1385 C 5131.2288,1385 5131.2288,1433 5116.2288,1459 C 5116.2288,1459 5101.2288,1482 5065.2288,1482 C 5065.2288,1482 5023.2288,1482 5011.2288,1460 C 5011.2288,1460 4998.2288,1442 4998.2288,1385 L 4998.2288,1377 C 4998.2288,1377 4998.2288,1323 5012.2288,1302 C 5012.2288,1302 5023.2288,1281 5065.2288,1281 z"
|
||||
id="path8630"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5500.2288,357 L 5488.2288,357 L 5470.2288,357 C 5470.2288,357 5449.2288,357 5440.2288,351 L 5402.2288,351 L 5370.2288,360 L 5340.2288,372 C 5340.2288,372 5334.2288,373 5331.2288,381 C 5331.2288,381 5327.2288,391 5333.2288,402 C 5333.2288,402 5340.2288,411 5352.2288,414 L 5390.2288,434 L 5399.2288,443 L 5452.2288,467 L 5478.2288,473 L 5514.2288,473 L 5526.2288,478 C 5526.2288,478 5532.2288,481 5537.2288,478 C 5537.2288,478 5544.2288,473 5556.2288,449 L 5564.2288,440 L 5564.2288,428 L 5565.2288,414 L 5552.2288,402 L 5544.2288,382 C 5544.2288,382 5531.2288,378 5525.2288,375 L 5513.2288,363 L 5500.2288,357"
|
||||
id="path8632"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5354.2288,508 L 5293.2288,518 L 5292.2288,525 C 5292.2288,525 5289.2288,531 5292.2288,538 L 5302.2288,558 L 5317.2288,577 L 5345.2288,600 C 5345.2288,600 5352.2288,608 5366.2288,611 L 5396.2288,612 L 5422.2288,617 C 5422.2288,617 5434.2288,618 5455.2288,630 L 5476.2288,627 L 5491.2288,627 L 5506.2288,620 C 5506.2288,620 5522.2288,617 5523.2288,615 C 5523.2288,615 5531.2288,612 5538.2288,599 L 5547.2288,576 L 5547.2288,567 L 5549.2288,559 C 5549.2288,559 5550.2288,553 5558.2288,550 L 5568.2288,544 L 5568.2288,523 C 5568.2288,523 5568.2288,520 5553.2288,517 L 5534.2288,515 L 5513.2288,518 L 5455.2288,505 L 5423.2288,503 L 5354.2288,508"
|
||||
id="path8634"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5213.2288,759 L 5222.2288,800 C 5222.2288,800 5218.2288,824 5221.2288,845 L 5222.2288,893 L 5221.2288,928 L 5237.2288,995 L 5240.2288,1002 L 5240.2288,1027 C 5240.2288,1027 5243.2288,1040 5254.2288,1045 L 5272.2288,1049 L 5296.2288,1060 L 5304.2288,1070 C 5304.2288,1070 5308.2288,1076 5314.2288,1075 C 5314.2288,1075 5319.2288,1073 5319.2288,1060 L 5331.2288,1039 L 5333.2288,1016 L 5339.2288,992 L 5343.2288,975 L 5352.2288,893 L 5348.2288,881 L 5340.2288,869 L 5342.2288,853 L 5342.2288,838 L 5345.2288,836 L 5390.2288,838 L 5407.2288,819 C 5407.2288,819 5432.2288,795 5448.2288,789 L 5455.2288,800 C 5455.2288,800 5461.2288,816 5457.2288,841 L 5423.2288,880 L 5402.2288,901 C 5402.2288,901 5389.2288,912 5386.2288,925 L 5379.2288,930 L 5378.2288,940 L 5376.2288,968 C 5376.2288,968 5376.2288,992 5379.2288,998 L 5392.2288,1007 L 5407.2288,1013 L 5419.2288,1023 L 5467.2288,1027 L 5517.2288,1017 L 5546.2288,1023 L 5584.2288,1022 L 5600.2288,1013 L 5612.2288,1008 L 5623.2288,996 L 5623.2288,980 L 5626.2288,943 L 5612.2288,936 L 5609.2288,934 L 5593.2288,921 L 5591.2288,909 L 5614.2288,848 L 5615.2288,815 C 5615.2288,815 5611.2288,807 5617.2288,791 L 5617.2288,760 L 5600.2288,741 L 5582.2288,714 L 5561.2288,697 L 5552.2288,689 L 5528.2288,683 L 5503.2288,685 L 5490.2288,679 L 5443.2288,711 L 5349.2288,757 L 5337.2288,762 L 5325.2288,765 C 5325.2288,765 5313.2288,765 5308.2288,760 L 5298.2288,748 L 5289.2288,738 L 5283.2288,723 L 5265.2288,715 L 5246.2288,707 C 5246.2288,707 5237.2288,703 5233.2288,694 L 5228.2288,688 L 5221.2288,691 L 5215.2288,729 L 5213.2288,759"
|
||||
id="path8636"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5314.2288,1515 L 5259.2288,1515 L 5259.2288,1246 L 5319.2288,1246 L 5455.2288,1442 L 5594.2288,1246 L 5647.2288,1246 L 5647.2288,1515 L 5573.2288,1515 L 5573.2288,1361 L 5463.2288,1515 L 5419.2288,1515 L 5314.2288,1361 L 5314.2288,1515"
|
||||
id="path8638"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
</svg>
|
||||
<!-- version: 20090314, original size: 5666.248 1554, border: 3% -->
|
||||
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1"
|
||||
id="svg2" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 392 293.6"
|
||||
style="enable-background:new 0 0 1000 293.6;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E60027;}
|
||||
</style>
|
||||
<sodipodi:namedview bordercolor="#000000" borderopacity="0.25" id="namedview1" inkscape:current-layer="svg2" inkscape:cx="500" inkscape:cy="146.85792" inkscape:deskcolor="#d1d1d1" inkscape:pagecheckerboard="0" inkscape:pageopacity="0.0" inkscape:showpageshadow="2" inkscape:window-height="705" inkscape:window-maximized="1" inkscape:window-width="1366" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:zoom="0.732" pagecolor="#ffffff">
|
||||
</sodipodi:namedview>
|
||||
<g id="g23530" transform="matrix(4.6372088,0,0,4.6372088,-2380.96,-486.51923)">
|
||||
<path id="path47920" class="st0" d="M538.7,145.6l5.1-5l-4-4l4-4.1l-5.1-5l-4,4l-2.7-2.7c-4.2-4.2-11.2-4.2-15.4,0
|
||||
c-2.1,2.1-3.2,4.9-3.2,7.7c0,2.8,1,5.6,3.2,7.7c4.2,4.3,11.2,4.3,15.4,0c0.9-0.9,1.8-1.8,2.7-2.7L538.7,145.6z M527,139.2
|
||||
c-1.5,1.5-3.9,1.5-5.3,0c-0.7-0.7-1.1-1.7-1.1-2.6c0-1,0.4-2,1.1-2.7c1.5-1.5,3.9-1.5,5.3,0c1.2,1.2,2.6,2.7,2.6,2.7
|
||||
S528.2,138,527,139.2 M594.2,128.9c-4.2-4.2-11.2-4.2-15.4,0c-1.2,1.2-2.7,2.7-2.7,2.7l-4-4l-5,5l4,4.1l-4,4l5,5l4-4
|
||||
c0,0,1.5,1.4,2.7,2.7c4.3,4.3,11.2,4.3,15.4,0c2.1-2.1,3.2-4.9,3.2-7.7C597.4,133.8,596.3,131,594.2,128.9 M589.1,139.2
|
||||
c-1.5,1.5-3.8,1.5-5.3,0c-1.2-1.2-2.7-2.6-2.7-2.6s1.5-1.5,2.7-2.7c1.5-1.5,3.9-1.5,5.3,0c0.7,0.7,1.1,1.7,1.1,2.7
|
||||
C590.2,137.5,589.9,138.5,589.1,139.2 M560.5,136.6c0,0,11.9-11.9,13-13c4.3-4.3,4.3-11.2,0-15.4c-4.2-4.2-11.1-4.2-15.4,0
|
||||
c-1.7,1.7-2.6,2.7-2.6,2.7s-1-1-2.6-2.7c-4.3-4.2-11.2-4.2-15.4,0c-4.3,4.3-4.3,11.2,0,15.4l13.1,13c0,0-11.9,11.8-13.1,13
|
||||
c-4.3,4.3-4.3,11.2,0,15.4c4.3,4.2,11.2,4.2,15.4,0c1.7-1.7,2.6-2.7,2.6-2.7s1,1,2.6,2.7c4.3,4.2,11.2,4.2,15.4,0
|
||||
c4.3-4.3,4.3-11.2,0-15.4C572.4,148.4,560.5,136.6,560.5,136.6 M542.4,118.5c-1.4-1.5-1.4-3.8,0-5.3c1.5-1.4,3.9-1.4,5.3,0l2.7,2.7
|
||||
l-4,4l5,5.1l4-4l4,4l5-5.1l-4-4c0,0,1.2-1.3,2.7-2.7c1.5-1.4,3.8-1.4,5.3,0c1.5,1.5,1.5,3.8,0,5.3c-1.5,1.5-13.1,13.1-13.1,13.1
|
||||
S543.9,120,542.4,118.5 M568.5,160c-1.5,1.4-3.8,1.4-5.3,0c-1.4-1.4-2.7-2.7-2.7-2.7l4-4l-5-5l-4,4l-4-4l-5,5l4,4
|
||||
c0,0-1.3,1.2-2.7,2.7c-1.4,1.4-3.8,1.4-5.3,0c-1.4-1.5-1.4-3.9,0-5.3c1.5-1.5,13-13.1,13-13.1s11.5,11.6,13.1,13.1
|
||||
C570,156.1,570,158.5,568.5,160"/>
|
||||
<g id="g23504">
|
||||
<path id="path23467" d="M644,149.2c-2.5-0.1-3.3,1.3-3.3,1.3l-0.1-1.2h-2.2v10.3h2.6v-6.3c0-1.3,0.8-2.4,2.3-2.4
|
||||
c1.5,0,2,1.4,2,2.4v6.3h2.4v-6.1C647.6,150.4,645.9,149.2,644,149.2"/>
|
||||
<path id="path23465" d="M621.8,157.1c-0.6,0.8-1.3,1.5-2.2,2c-1,0.6-2.2,0.9-3.5,0.9c-1.9,0-3.5-0.7-4.8-2c-1.3-1.3-2-2.9-2-4.8
|
||||
c0-2,0.7-3.7,2.3-5c1.3-1.2,2.8-1.8,4.5-1.8c1.2,0,2.4,0.3,3.5,0.9c0.9,0.5,1.6,1.2,2.2,2l-2.2,1.6c-0.1-0.4-0.4-0.7-0.6-1
|
||||
c-0.7-0.8-1.7-1.3-2.8-1.3c-1.1,0-2.1,0.5-2.8,1.3c-0.8,0.9-1.1,2-1.1,3.3c0,1.4,0.4,2.5,1.1,3.4c0.7,0.8,1.7,1.3,2.8,1.3
|
||||
c1.1,0,2.1-0.4,2.8-1.3c0.3-0.3,0.5-0.7,0.6-1C619.6,155.5,621.2,156.7,621.8,157.1"/>
|
||||
<path id="path23463" d="M622.7,144.7h2.6v5.8c0,0,0.9-1.3,3-1.3c1.9,0,3.6,1.2,3.6,4.4v6.1h-2.4v-6.3c0-1.1-0.4-2.4-1.9-2.4
|
||||
c-1.5,0-2.2,1.1-2.2,2.4v6.3h-2.6L622.7,144.7z"/>
|
||||
<path id="path47924" d="M657.3,149.4v1.2c-1-1-2.1-1.5-3.4-1.5c-1.4,0-2.7,0.5-3.6,1.5c-1,1-1.5,2.3-1.5,3.8
|
||||
c0,1.6,0.5,2.9,1.5,3.9c1,1,2.2,1.6,3.7,1.6c1.2,0,2.3-0.5,3.3-1.4v1.1h2.5v-10.3H657.3z M656.6,156.9c-0.6,0.6-1.3,0.9-2.3,0.9
|
||||
c-0.9,0-1.6-0.3-2.1-0.9c-0.5-0.6-0.8-1.4-0.8-2.4c0-0.9,0.3-1.7,0.8-2.3c0.6-0.6,1.3-0.9,2.2-0.9c0.9,0,1.6,0.3,2.2,0.9
|
||||
c0.6,0.6,0.9,1.4,0.9,2.3C657.4,155.5,657.1,156.2,656.6,156.9"/>
|
||||
<path id="path23481" class="st0" d="M689,145.5c-0.8,0-1.4,0.6-1.4,1.4c0,0.7,0.5,1.4,1.4,1.4h0c0.9,0,1.4-0.6,1.4-1.4
|
||||
C690.4,146.1,689.9,145.5,689,145.5z M687.8,149.4v10.3h2.4v-10.3H687.8z"/>
|
||||
<path id="path23487" class="st0" d="M635.1,145.5c-0.8,0-1.4,0.6-1.4,1.4c0,0.7,0.5,1.4,1.4,1.4h0c0.9,0,1.4-0.6,1.4-1.4
|
||||
C636.5,146.1,636,145.5,635.1,145.5z M633.9,149.3v10.4h2.4v-10.4H633.9z"/>
|
||||
<path id="path47930" d="M725.7,149.2c-1.5,0-2.6,0.6-3.5,1.6c-0.6-1.1-1.6-1.6-2.7-1.6c-2.2-0.1-3.1,1.3-3.1,1.3l-0.1-1.2H714
|
||||
v10.3h2.6v-6.2c0-1.3,0.7-2.4,2.1-2.4c1.4,0,1.8,1.3,1.8,2.4v6.2h2.4v-6.1v0c0-1.8,0.8-2.6,2-2.6c1.4,0,1.8,1.3,1.8,2.4v6.2h2.4
|
||||
v-6C729,150.5,727.5,149.3,725.7,149.2"/>
|
||||
<path id="path23475" d="M701.9,157.7c-0.4,0.7-1,1.2-1.7,1.6c-0.8,0.5-1.8,0.7-2.8,0.7c-1.5,0-2.8-0.6-3.8-1.6
|
||||
c-1.1-1-1.6-2.3-1.6-3.8c0-1.6,0.6-2.9,1.8-4c1-0.9,2.3-1.4,3.6-1.4c0.9,0,1.9,0.2,2.7,0.7c0.7,0.4,1.3,0.9,1.7,1.5l-1.9,1.4
|
||||
c-0.1-0.3-0.3-0.5-0.5-0.7c-0.5-0.6-1.2-1-2.1-1c-0.8,0-1.5,0.3-2,1c-0.6,0.6-0.8,1.5-0.8,2.5c0,1,0.3,1.9,0.8,2.5
|
||||
c0.5,0.6,1.2,0.9,2,0.9c0.8,0,1.5-0.3,2-1c0.2-0.2,0.4-0.5,0.5-0.8L701.9,157.7z"/>
|
||||
<path id="path23473" inkscape:label="path23473" d="M668.9,159.8c2.5,0,3.3-1.3,3.3-1.3l0.1,1.2h2.2v-10.3h-2.6v6.3
|
||||
c0,1.3-0.7,2.4-2.2,2.4c-1.5,0-2.1-1.4-2.1-2.4v-6.3H665v6C665,158.6,667,159.8,668.9,159.8"/>
|
||||
<path id="path23471" d="M682.3,149.2c-2.5,0-3.3,1.4-3.3,1.4l-0.1-1.2h-2.3v10.3h2.6v-6.2c0-1.4,0.8-2.4,2.2-2.4
|
||||
c1.5,0,2,1.3,2,2.4v6.2h2.4v-6C685.8,150.5,684.1,149.2,682.3,149.2"/>
|
||||
<path id="path47936" d="M707.4,149.1c1,0,1.9,0.2,2.7,0.7c0.9,0.5,1.5,1.2,2,1.9c0.5,0.8,0.7,1.8,0.7,2.7c0,1-0.3,1.9-0.7,2.7
|
||||
c-0.5,0.8-1.1,1.5-1.9,2c-0.8,0.5-1.8,0.7-2.7,0.7c-1.5,0-2.7-0.5-3.8-1.6c-1.1-1-1.6-2.3-1.6-3.8c0-1.6,0.6-2.9,1.8-4
|
||||
C704.9,149.6,706,149.1,707.4,149.1 M707.5,150.9c-0.8,0-1.5,0.3-2,1c-0.6,0.7-0.8,1.6-0.8,2.6c0,1.1,0.3,2,0.8,2.7
|
||||
c0.6,0.6,1.2,1,2.1,1c0.8,0,1.5-0.3,2.1-1c0.6-0.7,0.9-1.6,0.9-2.6c0-1-0.3-1.9-0.8-2.6C709.1,151.2,708.4,150.9,707.5,150.9"/>
|
||||
</g>
|
||||
<g id="g23510">
|
||||
<path id="path47940" d="M695.1,113.2l-3.6,5.6h-2.6l2.1-1.5c-1.8-2.5-3.4-4.1-3.4-4.1l-2,1.2l2.6,4.4H685v2.5h4.2v3.9H685v2.6h3.7
|
||||
l-4.4,11.3h1.9c1,0,2-0.7,2.4-1.6l2.7-6.8l3.7,8.4h3.7l-5.1-11.4h4.4v-2.6h-4.9v-3.9h4.9v-2.5h-3.2l2.9-3.7L695.1,113.2z
|
||||
M672.3,113.7v2.6h7.5v3.9h-3.1v-2.6h-3.4v15.7l-1.5,0.5v2.8l8-2.3v5.2h1.4c1.1,0,1.8-0.7,2.2-2.1v-4.1l1.7-0.5V130l-1.7,0.5
|
||||
v-14.2c0-1.6-1-2.6-2.2-2.6H672.3z M676.7,122.7h3.1v3.1h-3.1V122.7z M676.7,128.4h3.1v3.1l-3.1,0.9V128.4z"/>
|
||||
<path id="path47948" d="M633.2,116.7L633.2,116.7h-8.3v-3.6h-4.3v3.6h-5.4v2.6h5.4v10.3h-6.7v-12.9h-4.3v15.4h11.1v7.3h0.7
|
||||
c2.5-0.1,3.6-1.2,3.7-3v-4.3h7.3c2,0,3.6-1.5,3.7-3.6v-9.2C635.9,117.9,634.7,116.7,633.2,116.7 M631.6,128L631.6,128
|
||||
c0,0.9-0.7,1.6-1.6,1.6h0h-5v-10.3h6.7V128z"/>
|
||||
<path id="path47952" d="M641.1,114v25h22.4c2,0,3.6-1.6,3.6-3.7v-18.6c0-1.5-1.2-2.7-2.8-2.7h-17.9v2.5h16.7v18.4
|
||||
c0,0.9-0.7,1.6-1.6,1.6h-16.4V114H641.1z M646.5,119v2.5h5.3v3.1h-5.3v2.6h5.3v4.2h-5.4v2.5h15.4v-2.5h-1.9l1.3-1.6
|
||||
c-1.7-1.5-3-2.3-3-2.3l-1.5,1.5l2.2,2.4h-3v-4.2h5.9v-2.6h-5.9v-3.1h5.9V119H646.5z"/>
|
||||
<path id="path47960" d="M710,114.2v2.5h13.1l-4.3,2.6c-1.6-1.1-3.5-1.9-3.5-1.9L714,119l1.3,1.2h-5.2v15.2h1
|
||||
c1.1,0,2.4-0.8,2.4-2.3V131h4v4.4h1.1c1.3,0,2.3-0.7,2.3-2.3V131h4v1c0,0.5-0.6,0.8-1,0.8h-1.5l0.7,2.6h2.5c1.7,0,3.1-1.3,3.1-3.1
|
||||
v-10c0-1.2-1-2.1-2.1-2.1h-4l5.3-3.2v-2.9H710z M704.7,114.7l-2,1.4l3.4,4.5l2.6-1.9C706.7,116.1,704.7,114.7,704.7,114.7z
|
||||
M702.4,121.8v2.5h1.9v11.6c0,2,1.7,3.2,3.1,3.2h21.7v-2.5h-19.3c-0.9,0-1.7-0.8-1.7-1.7v-13.1H702.4z M713.7,122.7h4v1.7h-4
|
||||
V122.7z M721.1,122.7h4v1.7h-4V122.7z M713.7,126.9h4v1.7h-4V126.9z M721.1,126.9h4v1.7h-4V126.9z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,21 @@
|
||||
/** 前端自己的构建信息(构建时由 Vite 写进来,见 web/build-info.mjs)。 */
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __APP_TAG__: string
|
||||
declare const __APP_COMMIT__: string
|
||||
declare const __APP_BUILT_AT__: string
|
||||
|
||||
export const frontendBuild = {
|
||||
version: __APP_VERSION__,
|
||||
tag: __APP_TAG__,
|
||||
commit: __APP_COMMIT__,
|
||||
builtAt: __APP_BUILT_AT__,
|
||||
}
|
||||
|
||||
/** 构建时间显示成本地时间「2026-10-06 20:30」;没有(开发服务器)就是空。 */
|
||||
export function fmtBuiltAt(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface ChartSeries {
|
||||
color: string
|
||||
dash?: number[]
|
||||
fill?: boolean
|
||||
/** 原始数据的淡线(开了 EWMA 平滑时垫在平滑线后面):画细、画淡,图例和提示框里不出现。 */
|
||||
faint?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -22,14 +24,33 @@ interface Props {
|
||||
zero?: boolean
|
||||
/** 缺口:这些时间段用底色标出。 */
|
||||
gaps?: { from: number; to: number }[]
|
||||
/** 为真时 `t` 是「从起点算起的毫秒数」而不是 Unix 毫秒:横轴显示 0:00、0:15…,用来把两段时间叠在一起比。 */
|
||||
offset?: boolean
|
||||
}
|
||||
|
||||
/** 偏移量(毫秒)→ h:mm:ss / m:ss。 */
|
||||
export function fmtOffset(ms: number): string {
|
||||
const total = Math.round(ms / 1000)
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const sec = total % 60
|
||||
const two = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${two(m)}${sec ? ':' + two(sec) : ''}` : `${m}:${two(sec)}`
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
/** 毫秒到 1 秒以上换成秒(30000ms → 30s),坐标轴和提示框都不会出现一长串数字。 */
|
||||
function withUnit(v: number, unit: string, digits?: number): string {
|
||||
if (unit === 'ms' && Math.abs(v) >= 1000) return `${digits === undefined ? +(v / 1000).toFixed(1) : (v / 1000).toFixed(digits)}s`
|
||||
return `${v}${unit}`
|
||||
}
|
||||
|
||||
function formatValue(v: number | null | undefined, unit: string): string {
|
||||
if (v === null || v === undefined) return '—'
|
||||
if (unit === 'ms' && Math.abs(v) >= 1000) return withUnit(v, unit, 2)
|
||||
const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2)
|
||||
return `${text}${unit}`
|
||||
}
|
||||
@@ -40,12 +61,17 @@ function formatValue(v: number | null | undefined, unit: string): string {
|
||||
* - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间;
|
||||
* - 鼠标悬停显示这个时刻每条线的值。
|
||||
*/
|
||||
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) {
|
||||
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false }: Props) {
|
||||
const host = useRef<HTMLDivElement>(null)
|
||||
const tip = useRef<HTMLDivElement>(null)
|
||||
const plot = useRef<uPlot | null>(null)
|
||||
const zoom = useRef<{ min: number; max: number } | null>(null)
|
||||
const [zoomed, setZoomed] = useState(false)
|
||||
// 数据变了(轮询、实时)只更新数据,不重建图:重建会闪一下,还会丢掉鼠标位置
|
||||
const latest = useRef({ t, series, gaps })
|
||||
latest.current = { t, series, gaps }
|
||||
const structure = JSON.stringify([series.map((x) => [x.label, x.color, x.dash, x.fill, x.faint]), unit, zero, offset, height])
|
||||
const dataOf = (): AlignedData => [latest.current.t.map((v) => v / 1000), ...latest.current.series.map((x) => x.values.map((v) => (v === null ? null : v)))]
|
||||
|
||||
const reset = useCallback(() => {
|
||||
zoom.current = null
|
||||
@@ -70,12 +96,12 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
cursor: { drag: { x: true, y: false, setScale: false }, points: { size: 6 } },
|
||||
select: { show: true, left: 0, top: 0, width: 0, height: 0 },
|
||||
scales: {
|
||||
x: { time: true, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
|
||||
x: { time: !offset, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
|
||||
y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] },
|
||||
},
|
||||
axes: [
|
||||
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font },
|
||||
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => `${v}${unit}`) },
|
||||
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font, ...(offset ? { values: (_u: uPlot, ticks: number[]) => ticks.map((v) => fmtOffset(v * 1000)) } : {}) },
|
||||
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => withUnit(v, unit)) },
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
@@ -83,8 +109,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
const color = cssVar(s.color)
|
||||
return {
|
||||
label: s.label,
|
||||
stroke: color,
|
||||
width: 1.8,
|
||||
stroke: s.faint ? color + '55' : color,
|
||||
width: s.faint ? 1 : 1.8,
|
||||
dash: s.dash,
|
||||
fill: s.fill ? color + '22' : undefined,
|
||||
spanGaps: false,
|
||||
@@ -120,8 +146,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
return
|
||||
}
|
||||
const when = new Date(u.data[0][idx] * 1000)
|
||||
const lines = [when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })]
|
||||
for (let i = 0; i < series.length; i++) lines.push(`${series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`)
|
||||
const lines = [offset ? `起点后 ${fmtOffset(u.data[0][idx] * 1000)}` : when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })]
|
||||
for (let i = 0; i < latest.current.series.length; i++) if (!latest.current.series[i].faint) lines.push(`${latest.current.series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`)
|
||||
box.textContent = lines.join('\n')
|
||||
box.style.display = 'block'
|
||||
const width = box.offsetWidth
|
||||
@@ -135,7 +161,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
const { ctx } = u
|
||||
ctx.save()
|
||||
ctx.fillStyle = shade
|
||||
for (const g of gaps) {
|
||||
for (const g of latest.current.gaps) {
|
||||
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))
|
||||
const x1 = Math.min(u.bbox.left + u.bbox.width, u.valToPos(g.to / 1000, 'x', true))
|
||||
if (x1 > x0) ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height)
|
||||
@@ -145,8 +171,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
],
|
||||
},
|
||||
}
|
||||
const data: AlignedData = [t.map((v) => v / 1000), ...series.map((s) => s.values.map((v) => (v === null ? null : v)))]
|
||||
plot.current = new uPlot(options, data, el)
|
||||
plot.current = new uPlot(options, dataOf(), el)
|
||||
// 把提示框放进绘图区,这样它的位置和光标是同一个坐标系
|
||||
if (tip.current) plot.current.over.appendChild(tip.current)
|
||||
}
|
||||
@@ -161,12 +186,21 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
plot.current?.destroy()
|
||||
plot.current = null
|
||||
}
|
||||
}, [t, series, height, unit, zero, gaps, reset])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [structure, reset])
|
||||
|
||||
useEffect(() => {
|
||||
const u = plot.current
|
||||
if (!u) return
|
||||
u.setData(dataOf(), !zoom.current)
|
||||
u.redraw(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, series, gaps])
|
||||
|
||||
return (
|
||||
<div className="chart-wrap">
|
||||
<div className="chart-legend">
|
||||
{series.map((s) => (
|
||||
{series.filter((s) => !s.faint).map((s) => (
|
||||
<span key={s.label}>
|
||||
<i style={{ background: `var(${s.color})` }} />
|
||||
{s.label}
|
||||
|
||||
+10
-2
@@ -5,6 +5,8 @@ export interface QueryState<T> {
|
||||
data: T | undefined
|
||||
error: ApiError | Error | undefined
|
||||
loading: boolean
|
||||
/** 正在加载,而且不是定时刷新引起的(第一次加载、换了条件):用来显示进度条,定时刷新不打扰用户。 */
|
||||
busy: boolean
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
@@ -14,17 +16,23 @@ export function useQuery<T>(fetcher: () => Promise<T>, deps: unknown[], interval
|
||||
const [error, setError] = useState<ApiError | Error>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tick, setTick] = useState(0)
|
||||
const [silent, setSilent] = useState(false)
|
||||
const lastTick = useRef(0)
|
||||
const fetcherRef = useRef(fetcher)
|
||||
fetcherRef.current = fetcher
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
// 只有 tick 变了(定时刷新、reload)才算静默刷新;条件变了是用户能感觉到的加载
|
||||
setSilent(lastTick.current !== tick)
|
||||
lastTick.current = tick
|
||||
setLoading(true)
|
||||
fetcherRef
|
||||
.current()
|
||||
.then((value) => {
|
||||
if (!alive) return
|
||||
setData(value)
|
||||
// 轮询取到一模一样的数据时保持原来的对象,依赖它的图表不会白白重建(那是闪烁的来源)
|
||||
setData((previous) => (previous !== undefined && JSON.stringify(previous) === JSON.stringify(value) ? previous : value))
|
||||
setError(undefined)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
@@ -46,7 +54,7 @@ export function useQuery<T>(fetcher: () => Promise<T>, deps: unknown[], interval
|
||||
}, [intervalMs])
|
||||
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
return { data, error, loading, reload }
|
||||
return { data, error, loading, busy: loading && !silent, reload }
|
||||
}
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
@@ -143,6 +143,7 @@ export function translateCode(code: string): string | undefined {
|
||||
|
||||
const SETTINGS: Record<string, string> = {
|
||||
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
|
||||
'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent、Probe 的版本、tag 和构建时间',
|
||||
'retention.raw_days': '持续任务的原始数据块保留多少天',
|
||||
'retention.test_raw_days': '按需测试的原始数据块保留多少天',
|
||||
'retention.metric_10s_hours': '10 秒精度的指标保留多少小时',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from './api/client'
|
||||
import type { NodeInfoDto, Page } from './api/types'
|
||||
import { useQuery } from './hooks'
|
||||
import { OsIcon } from './osIcon'
|
||||
import mobileIcon from './assets/isp/mobile.svg?url'
|
||||
import telecomIcon from './assets/isp/telecom.svg?url'
|
||||
import unicomIcon from './assets/isp/unicom.svg?url'
|
||||
|
||||
/**
|
||||
* 节点(Agent、Probe)的展示信息:国旗、运营商、系统和架构。
|
||||
* 后端只给国家代码、运营商名、系统名和架构——没有 IP,所以这里也画不出 IP。
|
||||
*/
|
||||
|
||||
/** 所有国旗的 SVG,按需加载:用到哪个国家才会下载哪个文件。 */
|
||||
const flagLoaders = import.meta.glob<string>('../node_modules/flag-icons/flags/4x3/*.svg', { query: '?url', import: 'default' })
|
||||
|
||||
const regionNames = typeof Intl !== 'undefined' && 'DisplayNames' in Intl ? new Intl.DisplayNames(['zh-CN'], { type: 'region' }) : undefined
|
||||
|
||||
export function regionName(code: string): string {
|
||||
try {
|
||||
return regionNames?.of(code.toUpperCase()) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
export function Flag({ code }: { code: string }) {
|
||||
const [url, setUrl] = useState<string>()
|
||||
useEffect(() => {
|
||||
const load = flagLoaders[`../node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`]
|
||||
if (!load) {
|
||||
setUrl(undefined)
|
||||
return
|
||||
}
|
||||
let alive = true
|
||||
void load().then((u) => alive && setUrl(u))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [code])
|
||||
const name = regionName(code)
|
||||
if (!url) return <span className="flag ph" title={name} />
|
||||
return <img className="flag" src={url} alt={name} title={name} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营商的小图标:三大运营商用它们各自的标志(只取标志,不带文字),广电没有现成的文件,用品牌色方块加一个字代替;
|
||||
* 别的运营商没有图标,只显示名字。这些标志是各公司的商标,只用来标明「这是哪家运营商」。
|
||||
*/
|
||||
const ISP_ICON: Record<string, string> = { mobile: mobileIcon, telecom: telecomIcon, unicom: unicomIcon }
|
||||
const ISP_GLYPH: Record<string, string> = { broadnet: '广' }
|
||||
|
||||
export function IspIcon({ ispKey, name }: { ispKey: string; name?: string | null }) {
|
||||
const icon = ISP_ICON[ispKey]
|
||||
if (icon) return <img className="isp logo" src={icon} alt={name ?? ispKey} title={name ?? ispKey} />
|
||||
const glyph = ISP_GLYPH[ispKey]
|
||||
if (!glyph) return null
|
||||
return (
|
||||
<svg className={`isp isp-${ispKey}`} viewBox="0 0 16 16" role="img" aria-label={name ?? ispKey}>
|
||||
<title>{name ?? ispKey}</title>
|
||||
<rect width="16" height="16" rx="4" />
|
||||
<text x="8" y="12.2" textAnchor="middle">
|
||||
{glyph}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 节点的展示信息,按节点 id 查:`useNodeInfos().get(id)`。每分钟刷新一次。 */
|
||||
export function useNodeInfos(): Map<string, NodeInfoDto> {
|
||||
const { data } = useQuery(() => api.get<Page<NodeInfoDto>>('/api/public/nodes'), [], 60_000)
|
||||
return useMemo(() => new Map((data?.items ?? []).map((n) => [n.id, n])), [data])
|
||||
}
|
||||
|
||||
/** 系统和架构:「Debian 12 · arm64」。 */
|
||||
export const platformText = (info: NodeInfoDto | undefined) => [info?.os_name, info?.arch].filter(Boolean).join(' · ')
|
||||
|
||||
interface TagProps {
|
||||
info: NodeInfoDto | undefined
|
||||
/** 不写系统和架构,只留国旗和运营商。 */
|
||||
geoOnly?: boolean
|
||||
}
|
||||
|
||||
/** 国旗 + 运营商(图标,或者别的运营商的名字)+ 系统和架构。什么都没有就什么也不画。 */
|
||||
export function NodeTag({ info, geoOnly }: TagProps) {
|
||||
if (!info) return null
|
||||
const platform = geoOnly ? '' : platformText(info)
|
||||
if (!info.country && !info.isp_name && !platform) return null
|
||||
const title = [info.country ? regionName(info.country) : null, info.isp_name, platformText(info)].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<span className="node-tag" title={title}>
|
||||
{info.country ? <Flag code={info.country} /> : null}
|
||||
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||||
{info.isp_key === 'other' && info.isp_name ? <span className="isp-name">{info.isp_name}</span> : null}
|
||||
{platform ? (
|
||||
<span className="plat">
|
||||
<OsIcon os={info.os} name={info.os_name} />
|
||||
{platform}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
siAlmalinux,
|
||||
siAlpinelinux,
|
||||
siApple,
|
||||
siArchlinux,
|
||||
siCentos,
|
||||
siDebian,
|
||||
siFedora,
|
||||
siFreebsd,
|
||||
siKalilinux,
|
||||
siLinux,
|
||||
siLinuxmint,
|
||||
siNixos,
|
||||
siOpensuse,
|
||||
siOpenwrt,
|
||||
siRaspberrypi,
|
||||
siRedhat,
|
||||
siRockylinux,
|
||||
siUbuntu,
|
||||
} from 'simple-icons'
|
||||
|
||||
interface Glyph {
|
||||
/** 24×24 的图形路径。 */
|
||||
path: string
|
||||
/** 品牌色(6 位十六进制);黑色的图标不填,用文字颜色。 */
|
||||
color?: string
|
||||
}
|
||||
|
||||
const fromSimple = (icon: { path: string; hex: string }, useBrandColor = true): Glyph => ({ path: icon.path, color: useBrandColor ? icon.hex : undefined })
|
||||
|
||||
/** Windows 的四个窗格(simple-icons 没有收,自己画的最简形状)。 */
|
||||
const WINDOWS: Glyph = { path: 'M2 4.6 10.4 3.4v7.8H2zM11.6 3.2 22 1.8v9.4H11.6zM2 12.8h8.4v7.8L2 19.4zM11.6 12.8H22v9.4l-10.4-1.4z', color: '0078D4' }
|
||||
|
||||
/** 按系统名挑图标:先看 Windows、macOS,再按发行版名字找,认不出的 Linux 用企鹅。 */
|
||||
function pickGlyph(os: string | null | undefined, name: string | null | undefined): Glyph | undefined {
|
||||
if (os === 'windows') return WINDOWS
|
||||
if (os === 'macos') return fromSimple(siApple, false)
|
||||
const n = (name ?? '').toLowerCase()
|
||||
if (n.includes('debian')) return fromSimple(siDebian)
|
||||
if (n.includes('ubuntu')) return fromSimple(siUbuntu)
|
||||
if (n.includes('alpine')) return fromSimple(siAlpinelinux)
|
||||
if (n.includes('openwrt')) return fromSimple(siOpenwrt)
|
||||
if (n.includes('raspbian') || n.includes('raspberry')) return fromSimple(siRaspberrypi)
|
||||
if (n.includes('kali')) return fromSimple(siKalilinux)
|
||||
if (n.includes('mint')) return fromSimple(siLinuxmint)
|
||||
if (n.includes('rocky')) return fromSimple(siRockylinux)
|
||||
if (n.includes('alma')) return fromSimple(siAlmalinux, false)
|
||||
if (n.includes('centos')) return fromSimple(siCentos)
|
||||
if (n.includes('fedora')) return fromSimple(siFedora)
|
||||
if (n.includes('red hat') || n.includes('rhel')) return fromSimple(siRedhat)
|
||||
if (n.includes('arch')) return fromSimple(siArchlinux)
|
||||
if (n.includes('suse')) return fromSimple(siOpensuse)
|
||||
if (n.includes('nixos')) return fromSimple(siNixos)
|
||||
if (n.includes('freebsd')) return fromSimple(siFreebsd)
|
||||
if (os === 'linux') return fromSimple(siLinux, false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 系统的小图标(Debian、Ubuntu、Windows、macOS……)。认不出的系统不画。 */
|
||||
export function OsIcon({ os, name }: { os: string | null | undefined; name: string | null | undefined }) {
|
||||
const glyph = pickGlyph(os, name)
|
||||
if (!glyph) return null
|
||||
return (
|
||||
<svg className="os-icon" viewBox="0 0 24 24" role="img" aria-label={name ?? os ?? ''} style={glyph.color ? ({ '--os-c': `#${glyph.color}` } as React.CSSProperties) : undefined}>
|
||||
<title>{name ?? os ?? ''}</title>
|
||||
<path d={glyph.path} className={glyph.color ? 'brand' : 'plain'} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+374
-2
@@ -451,7 +451,8 @@ textarea {
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--app-scrim), transparent 60%);
|
||||
background: color-mix(in srgb, var(--app-scrim), transparent 55%);
|
||||
backdrop-filter: blur(3px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
@@ -465,7 +466,11 @@ textarea {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
box-shadow: 0 8px 32px color-mix(in srgb, var(--app-shadow), transparent 80%);
|
||||
border: 1px solid var(--app-outline-soft);
|
||||
/* 近处一圈细的、远处一大片柔的:边缘清楚,又不会糊成一团 */
|
||||
box-shadow:
|
||||
0 1px 2px color-mix(in srgb, var(--app-shadow), transparent 80%),
|
||||
0 12px 40px color-mix(in srgb, var(--app-shadow), transparent 72%);
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
@@ -635,3 +640,370 @@ pre.code {
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── 组件上的加载进度条:贴着卡片的上边缘,流动的渐变 ── */
|
||||
.has-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-bar i {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--chart-1);
|
||||
animation: loading-bar-slide 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-bar-slide {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 还没有数据时的占位 */
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 72px;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--app-card);
|
||||
}
|
||||
|
||||
.skeleton.chart {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 16px 16px auto;
|
||||
height: 12px;
|
||||
width: 40%;
|
||||
border-radius: 6px;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading-bar i {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 带标签切换的命令框 ── */
|
||||
.cmd-tabs {
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmd-tablist {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--app-outline);
|
||||
}
|
||||
|
||||
.cmd-tablist button {
|
||||
position: relative;
|
||||
padding: 10px 10px 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--app-ink-faint);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmd-tablist button:hover {
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.cmd-tablist button.on {
|
||||
color: var(--app-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cmd-tablist button.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--app-ink);
|
||||
}
|
||||
|
||||
.cmd-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
}
|
||||
|
||||
.cmd-body code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.cmd-copy {
|
||||
flex: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--app-ink-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmd-copy:hover {
|
||||
background: var(--app-card-hover);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
/* ── 数据源列表:连接中的高亮,点一行就切换 ── */
|
||||
.src-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.src-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.src-pick {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--app-outline-soft);
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.src-pick:hover {
|
||||
background: var(--app-card-hover);
|
||||
}
|
||||
|
||||
.src-row.on .src-pick,
|
||||
.src-pick.on {
|
||||
border-color: var(--app-accent);
|
||||
background: var(--app-accent-container);
|
||||
color: var(--app-on-accent-container);
|
||||
}
|
||||
|
||||
.src-dot {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.src-row.on .src-dot,
|
||||
.src-pick.on .src-dot {
|
||||
border-color: var(--app-accent);
|
||||
background: var(--app-accent);
|
||||
}
|
||||
|
||||
.src-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.src-text b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.src-text small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 数据源状态条:放在用户信息上方,一眼看到连的是哪个后端、连得上没有 ── */
|
||||
.src-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--app-outline-soft);
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card);
|
||||
color: var(--app-ink);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.src-status:hover {
|
||||
background: var(--app-card-hover);
|
||||
}
|
||||
|
||||
.src-status .st-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--app-idle);
|
||||
}
|
||||
|
||||
.src-status.ok .st-dot {
|
||||
background: var(--app-ok);
|
||||
}
|
||||
|
||||
.src-status.bad .st-dot {
|
||||
background: var(--app-bad);
|
||||
}
|
||||
|
||||
.src-status .st-host {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.src-status .st {
|
||||
flex: none;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.src-status.bad .st {
|
||||
color: var(--app-bad);
|
||||
}
|
||||
|
||||
/* ── 节点的国旗、运营商图标、系统和架构 ── */
|
||||
.node-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.node-tag .flag {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 13.5px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px var(--app-outline-soft);
|
||||
}
|
||||
|
||||
.node-tag .flag.ph {
|
||||
display: inline-block;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
.node-tag .isp {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* 三大运营商的标志:留出一点空,比例保持 */
|
||||
.node-tag .isp.logo {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.node-tag .isp rect {
|
||||
fill: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.node-tag .isp text {
|
||||
fill: var(--isp-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-tag .isp-mobile rect {
|
||||
fill: var(--isp-mobile);
|
||||
}
|
||||
|
||||
.node-tag .isp-telecom rect {
|
||||
fill: var(--isp-telecom);
|
||||
}
|
||||
|
||||
.node-tag .isp-unicom rect {
|
||||
fill: var(--isp-unicom);
|
||||
}
|
||||
|
||||
.node-tag .isp-broadnet rect {
|
||||
fill: var(--isp-broadnet);
|
||||
}
|
||||
|
||||
.node-tag .isp-name,
|
||||
.node-tag .plat {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
/* 系统的小图标:有品牌色的用品牌色(和文字色混一点,亮暗两个主题里都看得清),没有的跟文字走 */
|
||||
.os-icon {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.os-icon path.brand {
|
||||
fill: color-mix(in srgb, var(--os-c) 72%, var(--app-ink));
|
||||
}
|
||||
|
||||
.os-icon path.plain {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.node-tag .plat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
* 颜色只在这个文件里出现;其他样式一律引用令牌(stylelint 会拦住 hex / rgb / 具名色)。
|
||||
*/
|
||||
:root {
|
||||
/* 运营商小图标的底色(自己画的简化图标,不是官方 Logo) */
|
||||
--isp-mobile: #1c9be0;
|
||||
--isp-telecom: #1459a8;
|
||||
--isp-unicom: #d8261f;
|
||||
--isp-broadnet: #de6a1b;
|
||||
--isp-ink: #ffffff;
|
||||
|
||||
color-scheme: light;
|
||||
|
||||
--app-bg: #f4f2ed;
|
||||
@@ -59,6 +66,10 @@
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* 暗色下的阴影和遮罩要用纯黑,不然和深色背景差不多,看不出层次 */
|
||||
--app-shadow: #000000;
|
||||
--app-scrim: #000000;
|
||||
|
||||
--app-bg: #181714;
|
||||
--app-card: #211f1b;
|
||||
--app-card-inset: #2b2924;
|
||||
|
||||
+264
-33
@@ -1,8 +1,9 @@
|
||||
import { Check, Copy, Moon, Plug, Sun } from 'lucide-react'
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { ApiError } from '../api/client'
|
||||
import { getBase, getToken, normalizeBase, setSource } from '../api/source'
|
||||
import { useTheme } from '../hooks'
|
||||
import { Check, Copy, Moon, Plug, Plus, Sun, X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { ApiError, loginTo } from '../api/client'
|
||||
import { activateSource, getBase, listSources, normalizeBase, pingBackend, removeSource, setSource } from '../api/source'
|
||||
import { useQuery, useTheme } from '../hooks'
|
||||
import { translateCode, translateField, translateMessage } from '../i18n'
|
||||
|
||||
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
|
||||
@@ -64,14 +65,17 @@ export function Dialog({ title, onClose, children, actions }: { title: string; o
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
return (
|
||||
// 渲染到 body 上:对话框是 position: fixed 的,如果放在带 backdrop-filter / transform 的祖先里(比如展示端的顶栏),
|
||||
// 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
|
||||
return createPortal(
|
||||
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
{actions ? <div className="actions">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,49 +222,276 @@ export function ThemeToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */
|
||||
export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [base, setBase] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const remote = getBase() !== ''
|
||||
const show = () => {
|
||||
setBase(getBase())
|
||||
setToken(getToken())
|
||||
setOpen(true)
|
||||
/**
|
||||
* 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。
|
||||
* 数据在加载时流动,不加载时不占地方。
|
||||
*/
|
||||
export function LoadingBar({ active }: { active: boolean }) {
|
||||
return active ? (
|
||||
<div className="loading-bar" role="progressbar" aria-label="加载中">
|
||||
<i />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
export interface CommandTab {
|
||||
id: string
|
||||
label: string
|
||||
command: string
|
||||
}
|
||||
|
||||
/** 按浏览器所在的系统挑一个默认的标签(Windows / macOS / 其他当 Linux)。 */
|
||||
export function guessPlatform(): 'windows' | 'macos' | 'linux' {
|
||||
const text = `${navigator.platform} ${navigator.userAgent}`.toLowerCase()
|
||||
return text.includes('win') ? 'windows' : text.includes('mac') ? 'macos' : 'linux'
|
||||
}
|
||||
|
||||
/**
|
||||
* 带标签切换的命令框(curl / npm / bun 那样):上面一排下划线标签,下面是等宽的命令,右侧一个复制图标。
|
||||
* 记住上次选的标签(只是浏览器里的小方便)。
|
||||
*/
|
||||
export function CommandTabs({ tabs, initial, storageKey }: { tabs: CommandTab[]; initial?: string; storageKey?: string }) {
|
||||
const [active, setActive] = useState(() => {
|
||||
let saved: string | null = null
|
||||
try {
|
||||
saved = storageKey ? localStorage.getItem(storageKey) : null
|
||||
} catch {
|
||||
// 浏览器禁用存储时只是不记住
|
||||
}
|
||||
return [saved, initial].find((id) => id && tabs.some((t) => t.id === id)) ?? tabs[0]?.id ?? ''
|
||||
})
|
||||
const [done, setDone] = useState(false)
|
||||
const tab = tabs.find((t) => t.id === active) ?? tabs[0]
|
||||
if (!tab) return null
|
||||
const pick = (id: string) => {
|
||||
setActive(id)
|
||||
try {
|
||||
if (storageKey) localStorage.setItem(storageKey, id)
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
const apply = (nextBase: string, nextToken: string) => {
|
||||
setSource(nextBase, nextToken)
|
||||
location.reload()
|
||||
}
|
||||
return (
|
||||
<div className="cmd-tabs">
|
||||
<div className="cmd-tablist" role="tablist">
|
||||
{tabs.map((t) => (
|
||||
<button key={t.id} type="button" role="tab" aria-selected={t.id === tab.id} className={t.id === tab.id ? 'on' : ''} onClick={() => pick(t.id)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cmd-body">
|
||||
<code>{tab.command}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="cmd-copy"
|
||||
aria-label="复制命令"
|
||||
title="复制命令"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tab.command)
|
||||
setDone(true)
|
||||
window.setTimeout(() => setDone(false), 1500)
|
||||
} catch {
|
||||
// 没有剪贴板权限时用户可以手动选中
|
||||
}
|
||||
}}
|
||||
>
|
||||
{done ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面出错时显示提示,而不是整页白屏:说明出了什么错,可以重试(重新渲染这一块)或刷新整个页面。
|
||||
* 常见原因是前端和后端版本对不上(后端还没升级到新接口)。
|
||||
*/
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state: { error: Error | null } = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error(error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state
|
||||
if (!error) return this.props.children
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="card stack" style={{ maxWidth: 520 }}>
|
||||
<div>
|
||||
<h1>页面出错了</h1>
|
||||
<p className="muted">这一页没能显示出来。如果刚升级过,多半是前端和后端的版本对不上:先把后端升级到对应的版本,再刷新。</p>
|
||||
</div>
|
||||
<pre className="code">{error.message}</pre>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button type="button" className="btn primary" onClick={() => this.setState({ error: null })}>
|
||||
重试
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => location.reload()}>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 地址的简短写法:去掉协议,留 host(和端口)。 */
|
||||
const shortHost = (base: string) => base.replace(/^https?:\/\//i, '')
|
||||
|
||||
/**
|
||||
* 数据源:默认连这个页面所在的后端(同源);也可以保存几个别的后端(比如本机跑前端、连线上的),在列表里点一下就切换。
|
||||
* 跨域时不用 Cookie,改用 API 令牌,所以每个后端各存一个令牌(只在浏览器里),后端需要放行这个页面的来源(`Monitor__CorsOrigins`)。
|
||||
*/
|
||||
export function SourceButton({ variant = 'icon' }: { variant?: 'icon' | 'status' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [adding, setAdding] = useState(listSources().length === 0)
|
||||
const [base, setBase] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [, refresh] = useState(0)
|
||||
const active = getBase()
|
||||
const saved = listSources()
|
||||
const switchTo = (next: string) => {
|
||||
if (next !== active) {
|
||||
activateSource(next)
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
// 状态条用:每 15 秒问一下当前后端还连不连得上
|
||||
const health = useQuery(() => (variant === 'status' ? pingBackend() : Promise.resolve(undefined)), [variant, active], variant === 'status' ? 15_000 : undefined)
|
||||
const state = health.data === false ? 'bad' : health.data === undefined ? 'wait' : 'ok'
|
||||
const rows = saved.map((x) => ({ base: x.base, label: shortHost(x.base), detail: x.token ? '已登录' : '未登录' }))
|
||||
// 连接中的排在最前面
|
||||
rows.sort((a, b) => Number(b.base === active) - Number(a.base === active))
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={`btn icon${remote ? ' primary' : ''}`} onClick={show} aria-label="数据源" title={remote ? `数据源:${getBase()}` : '数据源:同源'}>
|
||||
{variant === 'status' ? (
|
||||
<button type="button" className={`src-status ${state}`} onClick={() => setOpen(true)} aria-label="数据源" title={`后端:${active || location.origin}(点击切换)`}>
|
||||
<span className="st-dot" />
|
||||
<span className="st-host">{shortHost(active || location.origin)}</span>
|
||||
<span className="st">{state === 'ok' ? '已连接' : state === 'bad' ? '无法连接' : '连接中…'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={`btn icon${active ? ' primary' : ''}`} onClick={() => setOpen(true)} aria-label="数据源" title={active ? `后端:${active}` : `后端:${location.origin}`}>
|
||||
<Plug size={18} />
|
||||
</button>
|
||||
)}
|
||||
{open ? (
|
||||
<Dialog
|
||||
title="数据源"
|
||||
onClose={() => setOpen(false)}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={() => apply('', '')}>
|
||||
恢复同源
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={() => apply(base, token)}>
|
||||
保存并刷新
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="stack">
|
||||
<p className="muted">默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(<code>Monitor__CorsOrigins</code>)。</p>
|
||||
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one;留空 = 同源'}>
|
||||
<input className="input" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
|
||||
{rows.length > 0 ? (
|
||||
<div className="src-list" role="listbox" aria-label="已保存的后端">
|
||||
{rows.map((row) => {
|
||||
const on = row.base === active
|
||||
return (
|
||||
<div key={row.base || 'same-origin'} className={`src-row${on ? ' on' : ''}`}>
|
||||
<button type="button" role="option" aria-selected={on} className="src-pick" onClick={() => switchTo(row.base)} title={on ? '正在连接' : '点击切换到这个后端'}>
|
||||
<span className="src-dot" />
|
||||
<span className="src-text">
|
||||
<b>{row.label}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
{on ? <span className="chip accent">连接中</span> : null}
|
||||
</button>
|
||||
{row.base ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn icon small"
|
||||
aria-label="忘掉这个后端"
|
||||
title={on ? '断开并忘掉这个后端' : '忘掉这个后端'}
|
||||
onClick={() => {
|
||||
removeSource(row.base)
|
||||
if (on) location.reload()
|
||||
else refresh((n) => n + 1)
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">还没有保存后端。填上后端地址和用户名、密码,就可以连上去;不填则使用 {location.origin} 的 API。</p>
|
||||
)}
|
||||
{adding ? (
|
||||
<div className="stack">
|
||||
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one'}>
|
||||
<input className="input" name="backend-url" type="url" autoComplete="url" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
|
||||
</Field>
|
||||
<Field label={tokenLabel}>
|
||||
<input className="input" type="password" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="off" />
|
||||
<Field label="用户名" hint="站点公开、只想看数据的话可以留空">
|
||||
<input className="input" name="backend-username" autoComplete="off" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className="input" name="backend-password" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</Field>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!normalizeBase(base) || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
// 填了用户名就先登录(拿回会话存起来),登录成功才保存这个后端
|
||||
setSource(base, username ? await loginTo(normalizeBase(base), username, password) : '')
|
||||
location.reload()
|
||||
} catch (err) {
|
||||
setError(describeError(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存并连接
|
||||
</button>
|
||||
{rows.length > 0 ? (
|
||||
<button type="button" className="btn" onClick={() => setAdding(false)}>
|
||||
取消
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn small"
|
||||
onClick={() => {
|
||||
setBase('')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setError('')
|
||||
setAdding(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加后端
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user