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:
justme committed 2026-10-06 21:24:25 +08:00
1 parent 10634bdd80
commit 14b0de3544
95 files changed
+5524 -528

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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="/// &lt;summary&gt;这个程序是什么时候、从哪个版本构建出来的(构建时生成)。&lt;/summary&gt;" />
<BuildInfoLine Include="public static class BuildInfo" />
<BuildInfoLine Include="{" />
<BuildInfoLine Include=" public const string Version = &quot;$(Version)&quot;%3B" />
<BuildInfoLine Include=" public const string Tag = &quot;$(MONITOR_TAG)&quot;%3B" />
<BuildInfoLine Include=" public const string Commit = &quot;$(MONITOR_COMMIT)&quot;%3B" />
<BuildInfoLine Include=" public const string BuiltAt = &quot;$(MONITOR_BUILT_AT)&quot;%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)
{
await tokens.RevokeAsync(id, ct);
if (purge == true)
{
await tokens.DeleteAsync(id, ct);
}
else
{
await tokens.RevokeAsync(id, ct);
}
return TypedResults.NoContent();
}
@@ -44,24 +44,35 @@ public static class AuthEndpoints
return Problems.From(http, error);
}
http.Response.Cookies.Append(
PrincipalResolver.SessionCookie,
login.SessionToken,
new CookieOptions
{
HttpOnly = true,
Secure = options.SecureCookies,
SameSite = SameSiteMode.Strict,
Path = "/",
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
});
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
bool returnSession = request.ReturnSession == true;
if (!returnSession)
{
http.Response.Cookies.Append(
PrincipalResolver.SessionCookie,
login.SessionToken,
new CookieOptions
{
HttpOnly = true,
Secure = options.SecureCookies,
SameSite = SameSiteMode.Strict,
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
View File
@@ -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` | 首次启动时通过命令行或环境变量创建初始管理员 |
任务的请求体示例:
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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 才能工作)。
+1 -1
View File
@@ -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>
+78 -34
View File
@@ -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}>
<div>
<h1>线路质量</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 />
<form className="card login-split" onSubmit={submit}>
<aside className="login-side">
<div>
<h1>MyMonitor</h1>
<p className="muted">管理端</p>
</div>
<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" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required />
</Field>
) : (
<>
<Field label="用户名">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus required />
</Field>
<Field label="密码">
<input className="input" 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}>
登录
</button>
<Field label="密码">
<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} />
+63
View File
@@ -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;
}
}
+25 -21
View File
@@ -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,35 +30,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.finally(() => setReady(true))
}, [])
const login = useCallback(async (username: string, password: string) => {
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)
setMe(m)
} catch (e) {
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 loginWithToken = useCallback(async (token: string) => {
setToken(token)
try {
const m = await api.get<MeDto>('/api/admin/auth/me')
setCsrf(m.csrf_token ?? null)
setMe(m)
} catch (e) {
setToken('')
throw e
}
}, [])
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 {
+8 -6
View File
@@ -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}>
<ToastProvider>
<AuthProvider>
<App />
</AuthProvider>
</ToastProvider>
<ErrorBoundary>
<ToastProvider>
<AuthProvider>
<App />
</AuthProvider>
</ToastProvider>
</ErrorBoundary>
</BrowserRouter>
</StrictMode>,
)
+45 -12
View File
@@ -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">
{state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<span className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
{state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Ban size={14} />
吊销
</button>
) : null}
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
吊销
删除
</button>
) : null}
</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}
</>
)
}
+3
View File
@@ -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">
+76 -3
View File
@@ -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>
</>
)
+6 -2
View File
@@ -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: '' },
+1
View File
@@ -0,0 +1 @@
export function buildDefines(dev: boolean): Record<string, string>
+25
View File
@@ -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
View File
@@ -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}` : 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)
+1 -1
View File
@@ -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>
+6 -4
View File
@@ -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>
</>
)
}
+29
View File
@@ -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>
)
}
+143
View File
@@ -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>
)
}
+258
View File
@@ -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
View File
@@ -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;
}
+6 -4
View File
@@ -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}>
<ToastProvider>
<App />
</ToastProvider>
<ErrorBoundary>
<ToastProvider>
<App />
</ToastProvider>
</ErrorBoundary>
</BrowserRouter>
</StrictMode>,
)
+3 -3
View File
@@ -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
View File
@@ -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>
))}
</div>
) : null
</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 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>
{chartSections}
{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>
) : null}
</div>
) : null}
<HostCard agentId={agentId} range={period.hostRange} />
{test.data && result ? (
<div className="card">
+81 -28
View File
@@ -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" />
{!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 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}
</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>
)
+182
View File
@@ -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 }
+130 -140
View File
@@ -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>
<span className="row" style={{ gap: 4 }}>
<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="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>
{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="上一段">
<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="下一段">
<ChevronRight size={16} />
</button>
</span>
) : 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>
)
}
+6 -1
View File
@@ -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: '' },
},
+30 -1
View File
@@ -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
View File
@@ -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"
}
}
+22
View File
@@ -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
View File
@@ -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;
+64 -4
View File
@@ -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
+5
View File
@@ -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']>
+67
View File
@@ -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

+142
View File
@@ -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

+78
View File
@@ -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

+21
View File
@@ -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())}`
}
+47 -13
View File
@@ -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
View File
@@ -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'
+1
View File
@@ -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 秒精度的指标保留多少小时',
+104
View File
@@ -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>
)
}
+69
View File
@@ -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
View File
@@ -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;
}
+11
View File
@@ -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;
+270 -39
View File
@@ -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)
}
const apply = (nextBase: string, nextToken: string) => {
setSource(nextBase, nextToken)
location.reload()
/**
* 组件上的加载进度条:放在卡片(`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 {
// 同上
}
}
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()}` : '数据源:同源'}>
<Plug size={18} />
</button>
{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>
<button type="button" className="btn primary" onClick={() => apply(base, token)}>
保存并刷新
</button>
</>
<button type="button" className="btn" onClick={() => setOpen(false)}>
关闭
</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 />
</Field>
<Field label={tokenLabel}>
<input className="input" type="password" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="off" />
</Field>
{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="用户名" 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}