Compare commits
5
Commits
e0f6667cd7
...
7fe9775f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fe9775f97 | ||
|
|
35207a70ea | ||
|
|
cfe6571db5 | ||
|
|
9f5a6cf77a | ||
|
|
48ffa56285 |
No files matched your search
@@ -4491,6 +4491,48 @@
|
||||
"x-moshiro-capability": "fleet.read"
|
||||
}
|
||||
},
|
||||
"/api/admin/links/reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-links"
|
||||
],
|
||||
"summary": "Drop every manual link setting and turn automatic linking on: who dials whom follows the default rules again",
|
||||
"operationId": "links_reset",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LinkMatrixDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-moshiro-capability": "fleet.write"
|
||||
}
|
||||
},
|
||||
"/api/admin/links/{dialer_id}/{listener_id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
|
||||
@@ -12,7 +12,7 @@ public static class MetricRegistry
|
||||
[
|
||||
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
|
||||
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.LossRunUp, LinkMetrics.LossRunDown, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
|
||||
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
|
||||
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.RetransUp, TcpMetrics.RetransDown, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
|
||||
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
|
||||
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
|
||||
SessionMetrics.HoldResets, SessionMetrics.HoldLifetime,
|
||||
@@ -24,7 +24,7 @@ public static class MetricRegistry
|
||||
[
|
||||
new("quic", "QUIC Datagram", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run.up_max", "loss.run.down_max", "reorder", "dup"]),
|
||||
new("udp", "QUIC Datagram (legacy alias)", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
|
||||
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.srtt.p95"]),
|
||||
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.retrans.up", "tcp.retrans.down", "tcp.srtt.p95"]),
|
||||
new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]),
|
||||
new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
|
||||
new("scores", "Scores", ["score.base", "score.mc", "score.hy2", "score.reality"]),
|
||||
|
||||
@@ -27,6 +27,14 @@ public static class TcpMetrics
|
||||
public static readonly MetricDef Retrans = Metric.Ratio(
|
||||
"tcp.retrans", s_tcpInfo, Ops.KernelDelta("retrans_total", "segs_out"), "TCP retransmissions", "Retransmitted segments as a share of segments sent (kernel counters).", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef RetransUp = Metric.Ratio(
|
||||
"tcp.retrans.up", s_tcpInfo, Ops.KernelDelta("retrans_total", "segs_out", TcpSide.Forward), "TCP retransmissions (forward)",
|
||||
"Retransmitted segments as a share of segments sent by the 拨号方 (kernel counters): loss on the 拨号方 to Agent path.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef RetransDown = Metric.Ratio(
|
||||
"tcp.retrans.down", s_tcpInfo, Ops.KernelDelta("retrans_total", "segs_out", TcpSide.Return), "TCP retransmissions (return)",
|
||||
"Retransmitted segments as a share of segments sent by the Agent (kernel counters): loss on the Agent to 拨号方 path.", good: 0.5, poor: 3);
|
||||
|
||||
public static readonly MetricDef Srtt = Metric.Histogram(
|
||||
"tcp.srtt", s_tcpInfo, Ops.Column("srtt_us", 0.001), "Kernel srtt", "Smoothed RTT sampled from the kernel.", Unit.Ms, [Stat.P95], good: 60, poor: 200);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class Ops
|
||||
|
||||
public static MetricOp RowCount { get; } = new RowCountOp();
|
||||
|
||||
public static MetricOp KernelDelta(string field, string over) => new KernelDeltaOp(field, over);
|
||||
public static MetricOp KernelDelta(string field, string over, TcpSide only = TcpSide.Both) => new KernelDeltaOp(field, over, only);
|
||||
|
||||
public static MetricOp Column(string column, double scale) => new ColumnValueOp(column, scale);
|
||||
|
||||
|
||||
@@ -9,19 +9,32 @@ internal static class TcpInfoTime
|
||||
side is not null && side.Values[row] == 0 ? view.DialerToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]);
|
||||
}
|
||||
|
||||
/// <summary>两次采样之间累计计数的差值(分子)与另一个累计计数的差值(分母),按 (side, conn) 分别计算;旧数据没有 conn 时按 side 计算。</summary>
|
||||
/// <summary>哪一侧的内核采样:拨号方 一侧发出的段走去程(拨号方到 Agent),Agent 一侧发出的段走回程。</summary>
|
||||
public enum TcpSide
|
||||
{
|
||||
Both,
|
||||
Forward,
|
||||
Return,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 两次采样之间累计计数的差值(分子)与另一个累计计数的差值(分母),按 (side, conn) 分别计算;旧数据没有 conn 时按 side 计算。
|
||||
/// 只取一侧时,另一侧的行整个跳过(基线也不记),所以去程和回程的重传各自只由自己这一侧发出的段算出。
|
||||
/// </summary>
|
||||
internal sealed class KernelDeltaOp : MetricOp
|
||||
{
|
||||
private readonly string _field;
|
||||
private readonly string _over;
|
||||
private readonly TcpSide _only;
|
||||
|
||||
public KernelDeltaOp(string field, string over)
|
||||
public KernelDeltaOp(string field, string over, TcpSide only = TcpSide.Both)
|
||||
{
|
||||
_field = field;
|
||||
_over = over;
|
||||
_only = only;
|
||||
}
|
||||
|
||||
public override string Signature => $"kernel_delta/{_field}/{_over}/2";
|
||||
public override string Signature => _only == TcpSide.Both ? $"kernel_delta/{_field}/{_over}/2" : $"kernel_delta/{_field}/{_over}/{_only}/2";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
@@ -43,7 +56,13 @@ internal sealed class KernelDeltaOp : MetricOp
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (side?.Values[i] ?? 0, connection?.IsValid(i) == true ? connection.Values[i] : 0);
|
||||
long rowSide = side?.Values[i] ?? 0;
|
||||
if ((_only == TcpSide.Forward && rowSide != 0) || (_only == TcpSide.Return && rowSide == 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (rowSide, connection?.IsValid(i) == true ? connection.Values[i] : 0);
|
||||
if (last.TryGetValue(key, out (long Ts, long Field, long Over) previous))
|
||||
{
|
||||
// Ignore delayed/duplicate samples without replacing the newer baseline.
|
||||
|
||||
@@ -9,7 +9,7 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 线路开关:哪些(拨号方,监听方)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>(不监听的节点自动拨所有监听节点);
|
||||
/// 线路开关:哪些(拨号方,监听方)要连,一对节点一条线路。自动连接的总开关是设置 <c>fleet.auto_link</c>(不监听的节点拨监听节点;两个都监听时 id 较小的一方拨);
|
||||
/// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 <c>PUT</c> 一个 <c>enabled: true</c>。
|
||||
/// </summary>
|
||||
public static class LinkEndpoints
|
||||
@@ -21,6 +21,9 @@ public static class LinkEndpoints
|
||||
group.MapGet("/links", ListAsync)
|
||||
.Api("links_list", "Every possible dialer/listener pair: whether it is linked, the manual override and whether it is connected; plus the nodes and which of them listen", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPost("/links/reset", ResetAsync)
|
||||
.Api("links_reset", "Drop every manual link setting and turn automatic linking on: who dials whom follows the default rules again", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPut("/links/{dialer_id}/{listener_id}", SetAsync)
|
||||
.Api("links_set", "Connect a node to a listening node (enabled: true), force the link off (false), or give it back to the automatic setting (null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404, 409, 422)
|
||||
.Requires(Capability.FleetWrite);
|
||||
@@ -29,16 +32,33 @@ public static class LinkEndpoints
|
||||
.Requires(Capability.FleetWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResetAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
await links.ResetAsync(ct);
|
||||
await agents.AfterAgentSetChangedAsync(ct);
|
||||
return await ListAsync(agents, links, runtime, time, ct);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
LinkSnapshot snapshot = await links.SnapshotAsync(ct);
|
||||
AgentView[] nodes = [.. (await agents.ListAsync(ct)).Where(n => n.Agent.Status == NodeStatus.Active).OrderBy(n => n.Agent.Name, StringComparer.Ordinal)];
|
||||
var items = new List<LinkDto>();
|
||||
foreach (AgentView dialer in nodes)
|
||||
for (int i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && n.Agent.Id != dialer.Agent.Id))
|
||||
for (int j = i + 1; j < nodes.Length; j++)
|
||||
{
|
||||
items.Add(ToDto(snapshot, dialer, listener, runtime, time));
|
||||
// 一对节点一条线路:连着的那个方向,没连就是默认方向;两个都不监听的一对没有线路
|
||||
(AgentView Dialer, AgentView Listener)? pair =
|
||||
snapshot.IsLinked(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j])
|
||||
: snapshot.IsLinked(nodes[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i])
|
||||
: snapshot.IsDefaultDialer(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j])
|
||||
: snapshot.IsDefaultDialer(nodes[j].Agent, nodes[i].Agent) ? (nodes[j], nodes[i])
|
||||
: null;
|
||||
if (pair is { } p)
|
||||
{
|
||||
items.Add(ToDto(snapshot, p.Dialer, p.Listener, runtime, time));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +168,7 @@ public static class LinkEndpoints
|
||||
|
||||
bool inBackoff = health?.Control.State == ControlState.Backoff;
|
||||
return new LinkDto(dialer.Agent.Id.Value, dialer.Agent.Name, listener.Agent.Id.Value, listener.Agent.Name, linked,
|
||||
snapshot.OverrideOf(dialer.Agent.Id.Value, listener.Agent.Id.Value), state == "connected", dialer.RuntimeStatus, listener.RuntimeStatus,
|
||||
snapshot.PairOverride(dialer.Agent.Id.Value, listener.Agent.Id.Value), state == "connected", dialer.RuntimeStatus, listener.RuntimeStatus,
|
||||
state, reason, (inBackoff ? health!.Control.LastError : health?.Task.Quic.LastError)?.ToString(),
|
||||
health is null ? 0 : inBackoff ? health.Control.RetryAttempt : health.Task.Quic.RetryAttempt,
|
||||
health is null ? null : inBackoff ? health.Control.RetryInMs : health.Task.Quic.RetryInMs);
|
||||
|
||||
@@ -80,6 +80,8 @@ public sealed partial class AgentService
|
||||
_gateway = gateway;
|
||||
_planner = planner;
|
||||
_settings = settings;
|
||||
// 线路的默认方向看国家(国内的节点默认只拨出):国家查到或变了就重新下发
|
||||
_nodes.OnCountryChanged = () => AfterAgentSetChangedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct)
|
||||
|
||||
@@ -4,27 +4,80 @@ namespace MoShiro.Backend.Application;
|
||||
public sealed class LinkSnapshot
|
||||
{
|
||||
private readonly Dictionary<(string Dialer, string Listener), bool> _overrides;
|
||||
private readonly HashSet<string> _domestic;
|
||||
|
||||
public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides)
|
||||
public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides, HashSet<string>? domestic = null)
|
||||
{
|
||||
Auto = auto;
|
||||
_overrides = overrides;
|
||||
_domestic = domestic ?? [];
|
||||
}
|
||||
|
||||
/// <summary>自动连接:开着时,所有节点自动拨其他监听节点;关着时,只有手动开启的才连。两个监听节点默认双向互连。</summary>
|
||||
/// <summary>自动连接:开着时,每一对节点自动连成一条线路;关着时,只有手动开启的才连。</summary>
|
||||
public bool Auto { get; }
|
||||
|
||||
/// <summary>手动设置:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 没设置(跟随自动连接)。</summary>
|
||||
/// <summary>手动设置:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 没设置(跟随自动连接)。只看这一个方向。</summary>
|
||||
public bool? OverrideOf(string dialerId, string listenerId) => _overrides.TryGetValue((dialerId, listenerId), out bool value) ? value : null;
|
||||
|
||||
/// <summary>这一对最终连不连:手动设置优先,没有就看自动连接。</summary>
|
||||
public bool IsLinked(AgentRecord dialer, AgentRecord listener) =>
|
||||
dialer.Id != listener.Id && (OverrideOf(dialer.Id.Value, listener.Id.Value) ?? Auto);
|
||||
/// <summary>这一对节点(不分方向)的手动设置:当前方向有设置就用它,否则看反方向。</summary>
|
||||
public bool? PairOverride(string dialerId, string listenerId) => OverrideOf(dialerId, listenerId) ?? OverrideOf(listenerId, dialerId);
|
||||
|
||||
/// <summary>
|
||||
/// 谁更适合当被连接的一方(越大越适合):不监听的是 0;监听的是 2,但国内(国家 CN)的监听节点是 1——国内节点默认只拨出,
|
||||
/// 除非对端没有更好的选择。手动指定的方向不受这个影响。
|
||||
/// </summary>
|
||||
private int ListenerRank(AgentRecord node) => !node.Listens ? 0 : _domestic.Contains(node.Id.Value) ? 1 : 2;
|
||||
|
||||
/// <summary>
|
||||
/// 自动连接时这一对由谁拨:更适合当被连接方的一方被拨;一样适合时 id 较小的一方拨。
|
||||
/// 所以不监听的拨监听的,国内的拨国外的,其余按 id。一对节点只有一条线路、一个连接、一份 RTT;上下行都在这条连接上测。
|
||||
/// </summary>
|
||||
public bool IsDefaultDialer(AgentRecord dialer, AgentRecord listener)
|
||||
{
|
||||
if (!listener.Listens)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int rank = ListenerRank(listener) - ListenerRank(dialer);
|
||||
return rank > 0 || (rank == 0 && string.CompareOrdinal(dialer.Id.Value, listener.Id.Value) < 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 这一对最终连不连、是不是由这个方向拨:手动设置优先(<c>true</c> 的方向就是拨号方,两个方向都设了 <c>true</c> 时按默认方向),
|
||||
/// 对端不监听时反方向拨不了;有 <c>false</c> 就整对不连;没有设置就看自动连接和默认方向。
|
||||
/// </summary>
|
||||
public bool IsLinked(AgentRecord dialer, AgentRecord listener)
|
||||
{
|
||||
if (dialer.Id == listener.Id || !listener.Listens)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool? forward = OverrideOf(dialer.Id.Value, listener.Id.Value);
|
||||
bool? reverse = OverrideOf(listener.Id.Value, dialer.Id.Value);
|
||||
if (forward == true)
|
||||
{
|
||||
return reverse != true || !dialer.Listens || IsDefaultDialer(dialer, listener);
|
||||
}
|
||||
|
||||
if (reverse == true && dialer.Listens)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (forward == false || reverse == false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Auto && IsDefaultDialer(dialer, listener);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:自动连接开着时,所有节点拨其他监听节点(默认),
|
||||
/// 关着时只连手动选中的;每一对还可以手动强制开或关,每个方向的线路独立设置。手动设置存在设置表里(<c>link.<拨号方>.<监听方></c>,值 1 或 0,空表示没设置)。
|
||||
/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 <see cref="LinkSnapshot.IsDefaultDialer"/>,国内节点默认只拨出),
|
||||
/// 关着时只连手动选中的;每一对还可以手动强制开或关,并且用 <c>true</c> 选定由哪一方拨。手动设置存在设置表里(<c>link.<拨号方>.<监听方></c>,值 1 或 0,空表示没设置),一对节点最多保留一条。
|
||||
/// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
|
||||
/// </summary>
|
||||
public sealed class LinkService
|
||||
@@ -32,10 +85,12 @@ public sealed class LinkService
|
||||
private const string Prefix = "link.";
|
||||
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public LinkService(ISettingsStore settings)
|
||||
public LinkService(ISettingsStore settings, NodeInfoService nodes)
|
||||
{
|
||||
_settings = settings;
|
||||
_nodes = nodes;
|
||||
}
|
||||
|
||||
public async ValueTask<LinkSnapshot> SnapshotAsync(CancellationToken ct)
|
||||
@@ -51,12 +106,27 @@ public sealed class LinkService
|
||||
}
|
||||
}
|
||||
|
||||
return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides);
|
||||
HashSet<string> domestic = [.. (await _nodes.ListAsync(ct)).Where(n => n.Country == "CN").Select(n => n.NodeId)];
|
||||
return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides, domestic);
|
||||
}
|
||||
|
||||
/// <summary>手动设置一对线路:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 取消手动设置。</summary>
|
||||
public ValueTask SetAsync(string dialerId, string listenerId, bool? enabled, CancellationToken ct) =>
|
||||
_settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
|
||||
/// <summary>手动设置一对线路:<c>true</c> 强制连并由 <paramref name="dialerId"/> 拨、<c>false</c> 强制不连、<c>null</c> 取消手动设置。反方向的旧设置同时清掉。</summary>
|
||||
public async ValueTask SetAsync(string dialerId, string listenerId, bool? enabled, CancellationToken ct)
|
||||
{
|
||||
await _settings.SetAsync($"{Prefix}{listenerId}.{dialerId}", "", ct);
|
||||
await _settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
|
||||
}
|
||||
|
||||
/// <summary>恢复默认连接模式:清掉所有手动设置并打开自动连接,一对节点谁拨谁完全按默认规则。</summary>
|
||||
public async ValueTask ResetAsync(CancellationToken ct)
|
||||
{
|
||||
foreach (string key in (await _settings.GetAllAsync(ct)).Where(kv => kv.Key.StartsWith(Prefix, StringComparison.Ordinal) && kv.Value.Length > 0).Select(kv => kv.Key))
|
||||
{
|
||||
await _settings.SetAsync(key, "", ct);
|
||||
}
|
||||
|
||||
await _settings.SetAsync(SettingsService.AutoLink, "true", ct);
|
||||
}
|
||||
|
||||
/// <summary>节点被删除:清掉所有涉及它的手动设置。</summary>
|
||||
public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct)
|
||||
|
||||
@@ -29,6 +29,9 @@ public sealed class NodeInfoService
|
||||
private readonly ConcurrentDictionary<string, long> _lastTry = new();
|
||||
private Task? _load;
|
||||
|
||||
/// <summary>某个节点的国家变了(第一次查到或查到了不同的结果)时调用:线路的默认方向取决于国家,需要重新下发。</summary>
|
||||
public Func<ValueTask>? OnCountryChanged { get; set; }
|
||||
|
||||
public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
@@ -148,15 +151,22 @@ public sealed class NodeInfoService
|
||||
}
|
||||
|
||||
string country = geo.Country.ToUpperInvariant();
|
||||
bool countryChanged = !string.Equals(current.Info.Country, country, StringComparison.Ordinal);
|
||||
if (current.Info.IspManual)
|
||||
{
|
||||
// 运营商是手动指定的:只更新国家,不覆盖运营商
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
}
|
||||
|
||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
if (countryChanged && OnCountryChanged is { } notify)
|
||||
{
|
||||
await notify();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -260,6 +260,23 @@ public sealed class OpTests
|
||||
Assert.Equal(300UL, retrans.Denominator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrans_ForwardAndReturnAreSplitBySide()
|
||||
{
|
||||
long ts = ChunkFactory.DialerUs(0);
|
||||
var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!);
|
||||
builder.Add(ts, 0, 40_000, 0, 10, 10, 1000, 0, 1);
|
||||
builder.Add(ts, 1, 40_000, 0, 10, 100, 2000, 0, 1);
|
||||
builder.Add(ts + 1_000_000, 0, 40_000, 0, 10, 11, 1100, 0, 1);
|
||||
builder.Add(ts + 1_000_000, 1, 40_000, 0, 10, 102, 2200, 0, 1);
|
||||
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100,
|
||||
_ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [builder.Build()]);
|
||||
var forward = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans.up").Items["tcp.retrans.up"];
|
||||
var back = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans.down").Items["tcp.retrans.down"];
|
||||
Assert.Equal((1UL, 100UL), (forward.Numerator, forward.Denominator));
|
||||
Assert.Equal((2UL, 200UL), (back.Numerator, back.Denominator));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_Outcomes_AreCountedByClass()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
@@ -26,24 +28,88 @@ public sealed class LinkTests : IClassFixture<BackendFixture>
|
||||
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["dialer_id"]!.GetValue<string>() == dialer && i["listener_id"]!.GetValue<string>() == agent);
|
||||
|
||||
[Fact]
|
||||
public async Task ListeningAgents_AutomaticallyLinkBothDirections_WithoutSelfLinks()
|
||||
public async Task ListeningAgents_AutomaticallyShareOneLine_DialedBySmallerId()
|
||||
{
|
||||
await using AgentSimulator first = await _backend.ConnectAgentAsync();
|
||||
await using AgentSimulator second = await _backend.ConnectAgentAsync();
|
||||
string a = first.NodeId.Value;
|
||||
string b = second.NodeId.Value;
|
||||
string[] ids = [.. new[] { first.NodeId.Value, second.NodeId.Value }.Order(StringComparer.Ordinal)];
|
||||
(string a, string b) = (ids[0], ids[1]); // a 拨 b
|
||||
AgentSimulator listener = a == first.NodeId.Value ? second : first;
|
||||
AgentSimulator dialer = a == first.NodeId.Value ? first : second;
|
||||
Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue<bool>());
|
||||
Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue<bool>());
|
||||
Assert.Null(await LinkAsync(b, a)); // 反方向不再有一条线路
|
||||
Assert.Null(await LinkAsync(a, a));
|
||||
using HttpResponseMessage self = await SetLinkAsync(a, a, true);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, self.StatusCode);
|
||||
await first.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == b));
|
||||
await second.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == a));
|
||||
await listener.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == a));
|
||||
await dialer.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != b)); // 拨出的一方不放行对端拨进来
|
||||
|
||||
using HttpResponseMessage off = await SetLinkAsync(a, b, false);
|
||||
off.EnsureSuccessStatusCode();
|
||||
Assert.False((await LinkAsync(a, b))!["linked"]!.GetValue<bool>());
|
||||
// 手动让 b 拨 a:线路换方向,仍然只有一条
|
||||
using HttpResponseMessage swap = await SetLinkAsync(b, a, true);
|
||||
swap.EnsureSuccessStatusCode();
|
||||
Assert.Null(await LinkAsync(a, b));
|
||||
Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue<bool>());
|
||||
await dialer.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == b));
|
||||
|
||||
// 关掉这一对:两个方向都不连,线路按默认方向显示,手动设置对这一对生效
|
||||
using HttpResponseMessage off = await SetLinkAsync(b, a, false);
|
||||
off.EnsureSuccessStatusCode();
|
||||
Assert.Null(await LinkAsync(b, a));
|
||||
JsonNode closed = (await LinkAsync(a, b))!;
|
||||
Assert.False(closed["linked"]!.GetValue<bool>());
|
||||
Assert.False(closed["override"]!.GetValue<bool>());
|
||||
await SetLinkAsync(b, a, null);
|
||||
Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
private static AgentRecord Node(string id, bool listens) =>
|
||||
new(new Fleet.Core.NodeId(id), id, NodeStatus.Active, [], [], "", 0, [], [], true, [], null, null, 0, 0, null, 0, null, 0, Listens: listens);
|
||||
|
||||
[Fact]
|
||||
public void DefaultDialer_NonListeningDials_ThenDomesticDialsForeign_ThenSmallerId()
|
||||
{
|
||||
AgentRecord home = Node("agt_a", listens: false);
|
||||
AgentRecord cn = Node("agt_b", listens: true);
|
||||
AgentRecord abroad = Node("agt_c", listens: true);
|
||||
AgentRecord abroad2 = Node("agt_d", listens: true);
|
||||
var snapshot = new LinkSnapshot(true, [], ["agt_b"]);
|
||||
|
||||
Assert.True(snapshot.IsLinked(home, cn)); // 不监听的拨监听的
|
||||
Assert.False(snapshot.IsLinked(cn, home));
|
||||
Assert.True(snapshot.IsLinked(cn, abroad)); // 国内的拨国外的,哪怕它的 id 更小
|
||||
Assert.False(snapshot.IsLinked(abroad, cn));
|
||||
Assert.True(snapshot.IsLinked(abroad, abroad2)); // 一样的按 id,小的拨
|
||||
Assert.False(snapshot.IsLinked(abroad2, abroad));
|
||||
Assert.True(snapshot.IsLinked(home, cn)); // 只有国内的在监听时,国内的也得当监听方
|
||||
|
||||
// 手动指定的方向不受国家影响:让国外的拨国内的
|
||||
var manual = new LinkSnapshot(true, new() { [("agt_c", "agt_b")] = true }, ["agt_b"]);
|
||||
Assert.True(manual.IsLinked(abroad, cn));
|
||||
Assert.False(manual.IsLinked(cn, abroad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Links_ResetDropsManualSettingsAndTurnsAutomaticLinkingOn()
|
||||
{
|
||||
await using AgentSimulator first = await _backend.ConnectAgentAsync();
|
||||
await using AgentSimulator second = await _backend.ConnectAgentAsync();
|
||||
string[] ids = [.. new[] { first.NodeId.Value, second.NodeId.Value }.Order(StringComparer.Ordinal)];
|
||||
await SetLinkAsync(ids[1], ids[0], true); // 手动让大 id 的拨小 id 的
|
||||
await SetAutoLinkAsync(false);
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage reset = await _backend.Admin.PostAsync("/api/admin/links/reset", BackendFixture.Json(new JsonObject()));
|
||||
reset.EnsureSuccessStatusCode();
|
||||
JsonNode matrix = JsonNode.Parse(await reset.Content.ReadAsStringAsync())!;
|
||||
Assert.True(matrix["auto_link"]!.GetValue<bool>());
|
||||
JsonNode link = (await LinkAsync(ids[0], ids[1]))!; // 回到默认方向:小 id 拨大 id
|
||||
Assert.True(link["linked"]!.GetValue<bool>());
|
||||
Assert.Null(link["override"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetAutoLinkAsync(true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+25
-17
@@ -375,15 +375,30 @@
|
||||
}
|
||||
|
||||
.link-board-edge.connected,
|
||||
.link-board-legend .connected {
|
||||
.link-board-edge.good,
|
||||
.link-board-legend .good {
|
||||
color: var(--app-ok);
|
||||
}
|
||||
|
||||
.link-board-edge.fair,
|
||||
.link-board-legend .fair {
|
||||
color: var(--app-warn);
|
||||
}
|
||||
|
||||
.link-board-edge.poor,
|
||||
.link-board-legend .poor {
|
||||
color: var(--app-bad);
|
||||
}
|
||||
|
||||
.link-board-edge.pending,
|
||||
.link-board-legend .pending {
|
||||
color: var(--app-warn);
|
||||
}
|
||||
|
||||
.link-board-edge.dim {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.link-board-edge.selected,
|
||||
.link-board-edge:focus-visible {
|
||||
color: var(--app-accent);
|
||||
@@ -400,6 +415,11 @@
|
||||
stroke-dasharray: 6 6;
|
||||
}
|
||||
|
||||
.link-board-edge.pending .link-board-edge-line {
|
||||
stroke-dasharray: 2 6;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.link-board-edge.selected .link-board-edge-line,
|
||||
.link-board-edge:hover .link-board-edge-line,
|
||||
.link-board-edge:focus-visible .link-board-edge-line {
|
||||
@@ -413,22 +433,6 @@
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.link-board-arrow.connected {
|
||||
fill: var(--app-ok);
|
||||
}
|
||||
|
||||
.link-board-arrow.pending {
|
||||
fill: var(--app-warn);
|
||||
}
|
||||
|
||||
.link-board-arrow.disabled {
|
||||
fill: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.link-board-arrow.selected {
|
||||
fill: var(--app-accent);
|
||||
}
|
||||
|
||||
.link-board-edge-label rect {
|
||||
fill: var(--app-card);
|
||||
stroke: var(--app-outline-soft);
|
||||
@@ -563,6 +567,10 @@
|
||||
border-top-style: dashed;
|
||||
}
|
||||
|
||||
.link-board-legend .pending::before {
|
||||
border-top-style: dotted;
|
||||
}
|
||||
|
||||
.link-board-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Maximize, Minus, Plus, RotateCcw, Server } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState, type PointerEvent } from 'react'
|
||||
import { useEffect, useRef, useState, type PointerEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { LinkDto, LinkMatrixDto } from '@shared/api/types'
|
||||
import { getBase } from '@shared/api/source'
|
||||
@@ -13,6 +13,10 @@ type Drag = { pointer: number; origin: Point; start: Point; node?: string }
|
||||
const WIDTH = 216
|
||||
const HEIGHT = 108
|
||||
const keyOf = (link: LinkDto) => `${link.dialer_id}/${link.listener_id}`
|
||||
/** 线路质量:摘要里的 RTT 中位数(毫秒)和总丢包率(0–1);线路 id 是 <监听方>~<拨号方>。 */
|
||||
export type LineQuality = { rtt: number | null; loss: number | null }
|
||||
export const lineIdOf = (link: LinkDto) => `${link.listener_id}~${link.dialer_id}`
|
||||
type Tone = 'good' | 'fair' | 'poor' | 'connected' | 'pending' | 'disabled'
|
||||
const clampScale = (scale: number) => Math.max(0.25, Math.min(2, scale))
|
||||
|
||||
function labelOf(link: LinkDto) {
|
||||
@@ -26,6 +30,24 @@ function labelOf(link: LinkDto) {
|
||||
return states[link.connection_state] ?? '连接中'
|
||||
}
|
||||
|
||||
function toneOf(link: LinkDto, quality?: LineQuality): Tone {
|
||||
if (!link.linked) return 'disabled'
|
||||
if (!link.connected) return 'pending'
|
||||
const rtt = quality?.rtt
|
||||
if (rtt == null) return 'connected'
|
||||
const loss = quality?.loss ?? 0
|
||||
if (rtt >= 250 || loss >= 0.05) return 'poor'
|
||||
if (rtt >= 100 || loss >= 0.01) return 'fair'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
function edgeText(link: LinkDto, quality?: LineQuality) {
|
||||
if (!link.linked || !link.connected) return labelOf(link)
|
||||
if (quality?.rtt == null) return '测量中'
|
||||
const loss = quality.loss ?? 0
|
||||
return `${Math.round(quality.rtt)} ms${loss >= 0.005 ? ` · ${(loss * 100).toFixed(1)}%` : ''}`
|
||||
}
|
||||
|
||||
function readPositions(key: string): Positions {
|
||||
try {
|
||||
const saved: unknown = JSON.parse(localStorage.getItem(key) ?? '{}')
|
||||
@@ -36,8 +58,9 @@ function readPositions(key: string): Positions {
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkBoard({ data, busy, onSet, onRetry }: {
|
||||
export function LinkBoard({ data, quality, busy, onSet, onRetry }: {
|
||||
data: LinkMatrixDto
|
||||
quality: Record<string, LineQuality>
|
||||
busy: boolean
|
||||
onSet: (link: LinkDto, enabled: boolean | null) => Promise<void>
|
||||
onRetry: (link: LinkDto) => Promise<void>
|
||||
@@ -47,13 +70,14 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
|
||||
const [view, setView] = useState<View>({ x: 0, y: 0, scale: 1 })
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [focusNode, setFocusNode] = useState<string | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const viewport = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<Drag | null>(null)
|
||||
const initialized = useRef(false)
|
||||
const marker = useId().replaceAll(':', '')
|
||||
const nodes = data.nodes
|
||||
const focused = data.items.find((link) => keyOf(link) === selected)
|
||||
const focusedQuality = focused ? quality[lineIdOf(focused)] : undefined
|
||||
const nodeIds = nodes.map((node) => node.id).join(',')
|
||||
|
||||
function pointOf(id: string, layout = positions): Point {
|
||||
@@ -120,6 +144,7 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
|
||||
if (event.button !== 0 || drag.current || (event.target as Element).closest('a, button, [data-edge]')) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
setFocusNode(node ?? null)
|
||||
viewport.current?.setPointerCapture(event.pointerId)
|
||||
drag.current = { pointer: event.pointerId, origin: { x: event.clientX, y: event.clientY }, start: node ? pointOf(node) : view, node }
|
||||
setDragging(true)
|
||||
@@ -156,7 +181,7 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
|
||||
<div className="link-board-layout">
|
||||
<section className="link-board" aria-label="Agent 节点画板">
|
||||
<div className="link-board-tools">
|
||||
<span className="muted">拖动节点排布 · 拖动空白平移 · 滚轮缩放</span>
|
||||
<span className="muted">一对节点一条线,线上是近 3 小时 RTT 中位数 · 点节点只看它的线路 · 拖动排布 · 滚轮缩放</span>
|
||||
<div className="row">
|
||||
<button type="button" className="btn small" aria-label="缩小画板" onClick={() => zoom(1 / 1.2)}><Minus size={16} /></button>
|
||||
<span className="link-board-scale">{Math.round(view.scale * 100)}%</span>
|
||||
@@ -168,8 +193,7 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
|
||||
<div ref={viewport} className={`link-board-viewport${dragging ? ' dragging' : ''}`} onPointerDown={(event) => start(event)} onPointerMove={move} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={end}>
|
||||
{!nodes.length ? <div className="link-board-empty"><Empty>还没有已启用的 Agent,请先添加并启用节点。</Empty></div> : null}
|
||||
<div className="link-board-world" style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}>
|
||||
<svg className="link-board-edges" width="1" height="1" aria-label="有向线路">
|
||||
<defs>{['connected', 'pending', 'disabled', 'selected'].map((tone) => <marker key={tone} id={`${marker}-${tone}`} markerWidth="8" markerHeight="8" refX="9" refY="5" viewBox="0 0 10 10" orient="auto"><path d="M 0 0 L 10 5 L 0 10 Z" className={`link-board-arrow ${tone}`} /></marker>)}</defs>
|
||||
<svg className="link-board-edges" width="1" height="1" aria-label="节点之间的线路">
|
||||
{data.items.map((link) => {
|
||||
const source = pointOf(link.dialer_id)
|
||||
const target = pointOf(link.listener_id)
|
||||
@@ -179,25 +203,26 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
|
||||
const ux = dx / length
|
||||
const uy = dy / length
|
||||
const margin = Math.min((WIDTH / 2 + 12) / Math.max(Math.abs(ux), 0.001), (HEIGHT / 2 + 12) / Math.max(Math.abs(uy), 0.001))
|
||||
const bend = 76
|
||||
const startX = source.x + WIDTH / 2 + ux * margin
|
||||
const startY = source.y + HEIGHT / 2 + uy * margin
|
||||
const endX = target.x + WIDTH / 2 - ux * margin
|
||||
const endY = target.y + HEIGHT / 2 - uy * margin
|
||||
const controlX = (startX + endX) / 2 - uy * bend
|
||||
const controlY = (startY + endY) / 2 + ux * bend
|
||||
const path = `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`
|
||||
const labelX = (startX + 2 * controlX + endX) / 4
|
||||
const labelY = (startY + 2 * controlY + endY) / 4
|
||||
const path = `M ${startX} ${startY} L ${endX} ${endY}`
|
||||
const labelX = (startX + endX) / 2
|
||||
const labelY = (startY + endY) / 2
|
||||
const active = selected === keyOf(link)
|
||||
const q = quality[lineIdOf(link)]
|
||||
const tone = toneOf(link, q)
|
||||
const text = edgeText(link, q)
|
||||
const dim = focusNode !== null && link.dialer_id !== focusNode && link.listener_id !== focusNode && !active
|
||||
return (
|
||||
<g key={keyOf(link)} data-edge className={`link-board-edge ${link.connected ? 'connected' : link.linked ? 'pending' : 'disabled'}${active ? ' selected' : ''}`} role="button" tabIndex={0} aria-label={`${link.dialer_name} → ${link.listener_name}:${labelOf(link)}`} aria-pressed={active} onClick={() => setSelected(keyOf(link))} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(keyOf(link)) } }}>
|
||||
<title>{link.dialer_name} → {link.listener_name}:{labelOf(link)}</title>
|
||||
<g key={keyOf(link)} data-edge className={`link-board-edge ${tone}${active ? ' selected' : ''}${dim ? ' dim' : ''}`} role="button" tabIndex={0} aria-label={`${link.dialer_name} 与 ${link.listener_name}:${text}`} aria-pressed={active} onClick={() => setSelected(keyOf(link))} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(keyOf(link)) } }}>
|
||||
<title>{link.dialer_name} 拨 {link.listener_name}:{labelOf(link)}{q?.rtt != null ? `,RTT ${Math.round(q.rtt)} ms` : ''}</title>
|
||||
<path className="link-board-edge-hit" d={path} />
|
||||
<path className="link-board-edge-line" d={path} markerEnd={`url(#${marker}-${active ? 'selected' : link.connected ? 'connected' : link.linked ? 'pending' : 'disabled'})`} />
|
||||
<path className="link-board-edge-line" d={path} />
|
||||
<g className="link-board-edge-label" transform={`translate(${labelX}, ${labelY})`}>
|
||||
<rect x="-52" y="-13" width="104" height="26" rx="13" />
|
||||
<text textAnchor="middle" dominantBaseline="central">{labelOf(link)}</text>
|
||||
<rect x="-40" y="-12" width="80" height="24" rx="12" />
|
||||
<text textAnchor="middle" dominantBaseline="central">{text}</text>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
@@ -220,29 +245,29 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="link-board-legend"><span className="connected">已连接</span><span className="pending">连接中</span><span className="disabled">已关闭</span></div>
|
||||
<div className="link-board-legend"><span className="good">RTT < 100 ms</span><span className="fair">100–250 ms 或丢包 ≥ 1%</span><span className="poor">> 250 ms 或丢包 ≥ 5%</span><span className="pending">连接中</span><span className="disabled">已关闭</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="link-board-inspector" aria-label="线路操作">
|
||||
<div className="card-title">线路详情</div>
|
||||
<select className="input" aria-label="选择线路" value={selected ?? ''} onChange={(event) => setSelected(event.target.value || null)}>
|
||||
<option value="">选择来源 → 目标</option>
|
||||
{data.items.map((link) => <option key={keyOf(link)} value={keyOf(link)}>{link.dialer_name} → {link.listener_name}</option>)}
|
||||
<option value="">选择一条线路</option>
|
||||
{data.items.map((link) => <option key={keyOf(link)} value={keyOf(link)}>{link.dialer_name} — {link.listener_name}</option>)}
|
||||
</select>
|
||||
{focused ? (
|
||||
<div className="stack">
|
||||
<div className="link-board-route"><Link to={`/agents/${focused.dialer_id}`}>{focused.dialer_name}</Link><span>→</span><Link to={`/agents/${focused.listener_id}`}>{focused.listener_name}</Link></div>
|
||||
<div className="link-board-route"><Link to={`/agents/${focused.dialer_id}`}>{focused.dialer_name}</Link><span>—</span><Link to={`/agents/${focused.listener_id}`}>{focused.listener_name}</Link></div>
|
||||
<div className="row wrap"><Chip tone={focused.connected ? 'ok' : focused.linked ? 'warn' : ''}>{labelOf(focused)}</Chip><span className="muted">{focused.override == null ? '跟随自动' : '手动设置'}</span></div>
|
||||
<dl className="kv"><dt>来源节点</dt><dd>{runtimeStatusLabel(focused.dialer_runtime_status)}</dd><dt>目标节点</dt><dd>{runtimeStatusLabel(focused.listener_runtime_status)}</dd><dt>重试次数</dt><dd>{focused.retry_attempt}</dd></dl>
|
||||
<dl className="kv">{focusedQuality?.rtt != null ? <><dt>RTT 中位数</dt><dd>{Math.round(focusedQuality.rtt)} ms</dd></> : null}{focusedQuality?.loss != null ? <><dt>丢包率</dt><dd>{(focusedQuality.loss * 100).toFixed(2)}%</dd></> : null}<dt>拨号方</dt><dd>{focused.dialer_name}({runtimeStatusLabel(focused.dialer_runtime_status)})</dd><dt>监听方</dt><dd>{focused.listener_name}({runtimeStatusLabel(focused.listener_runtime_status)})</dd><dt>重试次数</dt><dd>{focused.retry_attempt}</dd></dl>
|
||||
{focused.connection_reason ? <p className="muted">{focused.connection_reason}</p> : null}
|
||||
{focused.last_error ? <p className="notice warn">{focused.last_error}</p> : null}
|
||||
{focused.retry_in_ms ? <p className="muted">约 {Math.ceil(focused.retry_in_ms / 1000)} 秒后重试</p> : null}
|
||||
<button type="button" className={`btn${focused.linked ? '' : ' primary'}`} disabled={busy} onClick={() => onSet(focused, !focused.linked)}>{focused.linked ? '关闭此方向' : '开启此方向'}</button>
|
||||
<button type="button" className={`btn${focused.linked ? '' : ' primary'}`} disabled={busy} onClick={() => onSet(focused, !focused.linked)}>{focused.linked ? '关闭这条线路' : '开启这条线路'}</button>
|
||||
<button type="button" className="btn" disabled={busy || focused.override == null} onClick={() => onSet(focused, null)}>恢复自动</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onRetry(focused)}>立即重试</button>
|
||||
<span className="faint">操作只影响当前方向。</span>
|
||||
<span className="faint">一对节点只有一条线路,上下行都在这条连接上测,RTT 只测一次。</span>
|
||||
</div>
|
||||
) : <Empty>点击画板中的连线,查看状态或调整测量方向。</Empty>}
|
||||
) : <Empty>点击画板中的连线,查看线路质量或开关这条线路。</Empty>}
|
||||
<p className="link-board-storage faint">节点位置仅保存在当前浏览器。</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { LinkBoard } from './LinkBoard'
|
||||
import { LinkBoard, type LineQuality } from './LinkBoard'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { LinkDto, LinkMatrixDto } from '@shared/api/types'
|
||||
import type { LinkDto, LinkMatrixDto, OverviewDto } from '@shared/api/types'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { runtimeStatusLabel } from '@shared/nodes'
|
||||
import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
/**
|
||||
* 线路:Agent 之间的有向线路。自动连接开着时所有可达 Agent 相互连接,关着时只连手动选中的;
|
||||
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。来源 Agent 始终是主动拨出的一方。
|
||||
* 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的;
|
||||
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;国内的节点默认只拨出;其余 id 较小的拨),「恢复默认连接模式」清掉所有手动设置。
|
||||
*/
|
||||
/** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */
|
||||
const CONNECTION_LABELS: Record<string, string> = {
|
||||
@@ -20,6 +20,9 @@ const CONNECTION_LABELS: Record<string, string> = {
|
||||
|
||||
export function Links({ dialerId }: { dialerId?: string }) {
|
||||
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
|
||||
// 线路质量:画板上每条线写近 3 小时的 RTT 中位数和丢包,没有数据就只显示连接状态
|
||||
const overview = useQuery(() => (dialerId ? Promise.resolve(null) : api.get<OverviewDto>('/api/public/overview', { range: '3h', metrics: 'rtt.p50,loss.total', ip_version: 'ipv4' })), [dialerId], 30_000)
|
||||
const quality: Record<string, LineQuality> = Object.fromEntries((overview.data?.lines ?? []).map((line) => [line.id, { rtt: line.summary.metrics['rtt.p50'] ?? null, loss: line.summary.metrics['loss.total'] ?? null }]))
|
||||
const { busy, run } = useAction()
|
||||
const items = matrix.data?.items ?? []
|
||||
const dialers = (matrix.data?.nodes ?? []).filter((n) => !dialerId || n.id === dialerId).map((n) => [n.id, n.name] as const)
|
||||
@@ -33,6 +36,11 @@ export function Links({ dialerId }: { dialerId?: string }) {
|
||||
await run(() => api.post(`/api/admin/links/${link.dialer_id}/${link.listener_id}/retry`), '已要求 Agent 立即重试;若 Agent 离线,恢复后会自动重连')
|
||||
matrix.reload()
|
||||
}
|
||||
const resetLinks = async () => {
|
||||
if (!window.confirm('清掉所有手动设置并打开自动连接,每对 Agent 按默认规则重新连线?')) return
|
||||
await run(() => api.post('/api/admin/links/reset'), '已恢复默认连接模式')
|
||||
matrix.reload()
|
||||
}
|
||||
const setAuto = async (on: boolean) => {
|
||||
await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接')
|
||||
matrix.reload()
|
||||
@@ -43,14 +51,15 @@ export function Links({ dialerId }: { dialerId?: string }) {
|
||||
<div className={`row${dialerId ? '' : ' link-board-header'}`} style={{ marginBottom: 12 }}>
|
||||
<b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b>
|
||||
{matrix.loading && !matrix.data ? <Spinner /> : null}
|
||||
<span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : 'Agent 默认相互探测,点击连线管理方向。'}</span>
|
||||
{!dialerId ? <label className="switch right">
|
||||
<span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'}</span>
|
||||
{!dialerId ? <button type="button" className="btn small right" disabled={busy} title="默认规则:不监听的节点拨监听的;国内(CN)的节点默认只拨出、不当被连接方;其余按 id 小的拨。手动设置会被清掉。" onClick={resetLinks}>恢复默认连接模式</button> : null}
|
||||
{!dialerId ? <label className="switch">
|
||||
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
|
||||
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
|
||||
</label> : null}
|
||||
</div>
|
||||
<ErrorNotice error={matrix.error} />
|
||||
{!dialerId && matrix.data ? <LinkBoard data={matrix.data} busy={busy} onSet={set} onRetry={retry} /> : null}
|
||||
{!dialerId && matrix.data ? <LinkBoard data={matrix.data} quality={quality} busy={busy} onSet={set} onRetry={retry} /> : null}
|
||||
{dialerId && matrix.data && items.length === 0 ? <Empty>尚无可连接的线路,请添加并启用其他 Agent。</Empty> : null}
|
||||
{dialerId ? <table className="table">
|
||||
<thead>
|
||||
|
||||
@@ -24,16 +24,19 @@ const clock = (ms: number, showDate: boolean) => new Date(ms).toLocaleString('zh
|
||||
|
||||
interface Props {
|
||||
lineId: string
|
||||
/** 拨号方和监听方的名字:去程是拨号方到监听方,回程反过来。 */
|
||||
dialer: string
|
||||
listener: string
|
||||
range: OverviewRange
|
||||
/** 所选时间范围的汇总(左上、右上的数字)。 */
|
||||
m: Record<string, number | null | undefined>
|
||||
}
|
||||
|
||||
/** 一条线路的 QUIC/TCP 双行摘要,各自显示时延、质量点阵和丢包/重传率。 */
|
||||
export function LineStrips({ lineId, range, m }: Props) {
|
||||
/** 一条线路的 QUIC/TCP 摘要:RTT 是往返(⇄);每个方向一行,写明是「拨号方 → 监听方」还是反过来;QUIC 看各方向丢包,TCP 看各方向重传,都配质量点阵。 */
|
||||
export function LineStrips({ lineId, dialer, listener, 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: 'now', step: info.step, preset: 'quic,tcp', metrics: 'loss.up,loss.down,loss.total,rtt.p50,rtt.p95,rtt.p99,tcp.retrans,tcp.rtt.p50' }),
|
||||
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'now', step: info.step, preset: 'quic,tcp', metrics: 'loss.up,loss.down,loss.total,rtt.p50,rtt.p95,rtt.p99,tcp.retrans.up,tcp.retrans.down,tcp.rtt.p50' }),
|
||||
[lineId, info.value],
|
||||
30_000,
|
||||
)
|
||||
@@ -57,53 +60,44 @@ export function LineStrips({ lineId, range, m }: Props) {
|
||||
const at = shown !== null && t[shown] !== undefined ? shown : null
|
||||
const lossUp = col('loss.up')
|
||||
const lossDown = col('loss.down')
|
||||
const retrans = col('tcp.retrans')
|
||||
const up = m['loss.up']
|
||||
const down = m['loss.down']
|
||||
const upText = up === null || up === undefined ? '丢包 —' : `${fmtPct(up)} 丢包`
|
||||
const downText = down === null || down === undefined ? '丢包 —' : `${fmtPct(down)} 丢包`
|
||||
const retransValue = m['tcp.retrans']
|
||||
const retransTone = levelOf(retransValue)
|
||||
const retransText = retransValue === null || retransValue === undefined ? '重传 —' : `${fmtPct(retransValue)} 重传`
|
||||
const retransUp = col('tcp.retrans.up')
|
||||
const retransDown = col('tcp.retrans.down')
|
||||
const lossText = (v: number | null | undefined) => (v === null || v === undefined ? '丢包 —' : `${fmtPct(v)} 丢包`)
|
||||
const retransText = (v: number | null | undefined) => (v === null || v === undefined ? '重传 —' : `${fmtPct(v)} 重传`)
|
||||
const cellsOf = (values: (number | null)[]) => (s ? values : Array.from({ length: common.cells }, () => null))
|
||||
// 一个方向下的一行:协议名、数值徽标和质量点阵。
|
||||
const row = (id: string, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => (
|
||||
<div className="line-mini-row" key={id}>
|
||||
<div className="line-mini-direction-head" title={`${proto} ${label}`}>
|
||||
<span>{proto}</span>
|
||||
<span className={`line-mini-badge ${levelOf(value)}`}>{text}</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 ${proto} ${label}`}>
|
||||
{cellsOf(values).map((v, i) => (
|
||||
<span key={`${id}-${t[i] ?? i}`} className={`bar ${s ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||
<div className="line-mini-stack">
|
||||
<div className="line-mini-quic-head" title={`QUIC RTT p50 ${fmtMs(m['rtt.p50'])} · p95 ${fmtMs(m['rtt.p95'])} · p99 ${fmtMs(m['rtt.p99'])}`}>
|
||||
<span>QUIC</span><strong className="num">{fmtMs(m['rtt.p50'])}</strong><small>悬停看 p95 / p99</small>
|
||||
<div className="line-mini-quic-head" title={`RTT 是往返。QUIC p50 ${fmtMs(m['rtt.p50'])} · p95 ${fmtMs(m['rtt.p95'])} · p99 ${fmtMs(m['rtt.p99'])};TCP p50 ${fmtMs(m['tcp.rtt.p50'])}`}>
|
||||
<span>RTT ⇄</span>
|
||||
<span className="line-mini-rtt">QUIC <strong className="num">{fmtMs(m['rtt.p50'])}</strong></span>
|
||||
<span className="line-mini-rtt">TCP <strong className="num">{fmtMs(m['tcp.rtt.p50'])}</strong></span>
|
||||
<small>悬停看 p95 / p99</small>
|
||||
{series.error ? <span className="line-mini-badge bad" title={`上一次取数失败:${series.error.message}`}>数据未更新</span> : null}
|
||||
</div>
|
||||
<div className="line-mini-direction">
|
||||
<div className="line-mini-direction-head" title={`去程 拨号方 → 监听方 丢包 ${fmtPct(up)}`}>
|
||||
<span>去程</span>
|
||||
<span className={`line-mini-badge ${levelOf(up)}`}>{upText}</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的去程丢包`}>
|
||||
{(s ? lossUp : Array.from({ length: common.cells }, () => null)).map((value, i) => (
|
||||
<span key={`up-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="line-mini-route" title={`去程:${dialer} → ${listener}`}>{dialer} → {listener}</div>
|
||||
{row('up-quic', 'QUIC', '去程丢包', m['loss.up'], lossText(m['loss.up']), lossUp)}
|
||||
{row('up-tcp', 'TCP', '去程重传', m['tcp.retrans.up'], retransText(m['tcp.retrans.up']), retransUp)}
|
||||
</div>
|
||||
<div className="line-mini-direction">
|
||||
<div className="line-mini-direction-head" title={`回程 监听方 → 拨号方 丢包 ${fmtPct(down)}`}>
|
||||
<span>回程</span>
|
||||
<span className={`line-mini-badge ${levelOf(down)}`}>{downText}</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的回程丢包`}>
|
||||
{(s ? lossDown : Array.from({ length: common.cells }, () => null)).map((value, i) => (
|
||||
<span key={`down-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="line-mini-tcp">
|
||||
<div className="line-mini-tcp-head" title={`TCP RTT p50 ${fmtMs(m['tcp.rtt.p50'])}`}>
|
||||
<span>TCP</span><strong className="num">{fmtMs(m['tcp.rtt.p50'])}</strong>
|
||||
<span className={`line-mini-badge ${retransTone}`}>{retransText}</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 TCP 重传`}>
|
||||
{(s ? retrans : Array.from({ length: common.cells }, () => null)).map((value, i) => (
|
||||
<span key={`retrans-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="line-mini-route" title={`回程:${listener} → ${dialer}`}>{listener} → {dialer}</div>
|
||||
{row('down-quic', 'QUIC', '回程丢包', m['loss.down'], lossText(m['loss.down']), lossDown)}
|
||||
{row('down-tcp', 'TCP', '回程重传', m['tcp.retrans.down'], retransText(m['tcp.retrans.down']), retransDown)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||
@@ -127,7 +121,10 @@ export function LineStrips({ lineId, range, m }: Props) {
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">TCP</span>
|
||||
<span>
|
||||
重传 <em className={levelOf(col('tcp.retrans')[at])}>{fmtPct(col('tcp.retrans')[at])}</em>
|
||||
去程重传 <em className={levelOf(col('tcp.retrans.up')[at])}>{fmtPct(col('tcp.retrans.up')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
回程重传 <em className={levelOf(col('tcp.retrans.down')[at])}>{fmtPct(col('tcp.retrans.down')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
|
||||
|
||||
+29
-17
@@ -1388,15 +1388,13 @@
|
||||
}
|
||||
|
||||
.line-mini-quic-head > span,
|
||||
.line-mini-direction-head > span:first-child,
|
||||
.line-mini-tcp-head > span:first-child {
|
||||
.line-mini-direction-head > span:first-child {
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line-mini-quic-head > strong,
|
||||
.line-mini-tcp-head > strong {
|
||||
.line-mini-quic-head strong {
|
||||
color: var(--app-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -1410,15 +1408,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-mini-direction,
|
||||
.line-mini-tcp {
|
||||
.line-mini-direction {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.line-mini-direction-head,
|
||||
.line-mini-tcp-head {
|
||||
.line-mini-direction-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
@@ -1426,27 +1422,43 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.line-mini-direction-head > span:first-child,
|
||||
.line-mini-tcp-head > span:first-child {
|
||||
.line-mini-direction-head > span:first-child {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-mini-direction-head .line-mini-badge,
|
||||
.line-mini-tcp-head .line-mini-badge {
|
||||
.line-mini-direction-head .line-mini-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.line-mini-tcp {
|
||||
.line-mini-route {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--app-ink);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-mini-row {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.line-mini-rtt {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.line-mini-direction + .line-mini-direction {
|
||||
margin-top: 2px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid var(--app-outline-soft);
|
||||
}
|
||||
|
||||
.line-mini-tcp-head > strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.line-mini-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -22,14 +22,16 @@ 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: 'QUIC 丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'QUIC 上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: 'QUIC 下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.run.up_max', label: 'QUIC 上行最长连续丢包', unit: '', better: 'lower' },
|
||||
{ id: 'loss.run.down_max', label: 'QUIC 下行最长连续丢包', unit: '', better: 'lower' },
|
||||
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'QUIC 去程丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: 'QUIC 回程丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.run.up_max', label: 'QUIC 去程最长连续丢包', unit: '', better: 'lower' },
|
||||
{ id: 'loss.run.down_max', label: 'QUIC 回程最长连续丢包', unit: '', better: 'lower' },
|
||||
{ id: 'jitter.up.p95', label: 'QUIC 去程抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'jitter.down.p95', label: 'QUIC 回程抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
|
||||
{ id: 'tcp.retrans', label: 'TCP 重传', unit: '%', better: 'lower' },
|
||||
{ id: 'tcp.retrans.up', label: 'TCP 去程重传', unit: '%', better: 'lower' },
|
||||
{ id: 'tcp.retrans.down', label: 'TCP 回程重传', unit: '%', better: 'lower' },
|
||||
{ id: 'score.reality', label: 'REALITY 评分', unit: '', better: 'higher' },
|
||||
{ id: 'score.hy2', label: 'Hy2 评分', unit: '', better: 'higher' },
|
||||
{ id: 'score.mc', label: 'MC 评分', unit: '', better: 'higher' },
|
||||
|
||||
@@ -38,7 +38,8 @@ const TILES: { group: Exclude<MetricGroup, 'score'>; label: string; key: string;
|
||||
{ group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ group: 'tcp-connect', label: 'TCP 去程重传率', key: 'tcp.retrans.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ group: 'tcp-connect', label: 'TCP 回程重传率', key: 'tcp.retrans.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ group: 'tcp-connect', label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ group: 'tcp-connect', label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ group: 'tcp-connect', label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
|
||||
@@ -64,11 +65,11 @@ const GROUPS = [
|
||||
|
||||
const CHARTS: ChartDef[] = [
|
||||
{ id: 'rtt', title: '延迟', group: 'quic', 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: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||||
{ id: 'loss', title: '丢包', group: 'quic', 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: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '上行', color: '--chart-2' }, { key: 'loss.run.down_max', label: '下行', color: '--chart-3' }] },
|
||||
{ id: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '去程', color: '--chart-2' }, { key: 'jitter.down.p95', label: '回程', color: '--chart-1' }] },
|
||||
{ id: 'loss', title: '丢包', group: 'quic', 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: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '去程', color: '--chart-2' }, { key: 'loss.run.down_max', label: '回程', color: '--chart-3' }] },
|
||||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', 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-connect', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
||||
{ id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', series: [{ key: 'tcp.retrans.up', label: '去程', color: '--chart-2' }, { key: 'tcp.retrans.down', label: '回程', color: '--chart-3' }, { key: 'tcp.retrans', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||||
{ id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
|
||||
{ id: 'connect-ok', title: '建连成功率', group: 'tcp-connect', unit: '%', zero: false, series: [{ key: 'connect.success', 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] }] },
|
||||
@@ -316,7 +317,7 @@ export function Line() {
|
||||
<div className="line-combo-card">
|
||||
<h3>QUIC Datagram 丢包</h3>
|
||||
<div className="line-combo-primary"><span>总丢包</span><strong className={`combo-tone-${tone(m['loss.total'], 0.5, 3)}`}>{fmtPct(m['loss.total'])}</strong></div>
|
||||
<div className="line-combo-secondary"><span>↑ {fmtPct(m['loss.up'])}</span><span>/</span><span>↓ {fmtPct(m['loss.down'])}</span></div>
|
||||
<div className="line-combo-secondary"><span>去 {fmtPct(m['loss.up'])}</span><span>/</span><span>回 {fmtPct(m['loss.down'])}</span></div>
|
||||
<span className="line-combo-badge">最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包</span>
|
||||
</div>
|
||||
<div className="line-combo-card">
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PageError } from '../lib'
|
||||
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
||||
|
||||
const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
||||
const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans', 'tcp.retrans.up', 'tcp.retrans.down'].join(',')
|
||||
|
||||
type SortKey = 'name' | 'rtt' | 'loss'
|
||||
|
||||
@@ -136,7 +136,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条线路(拨号方 → 监听方):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
|
||||
/** 一条线路(拨号方 ⇄ 监听方):RTT 是往返;下面每个方向一行,写明是 A → B 还是 B → A(A 是拨号方)。QUIC 看两个方向的丢包,TCP 看两个方向的重传。 */
|
||||
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
||||
const dialerInfo = infos.get(l.dialer_id)
|
||||
const agentInfo = infos.get(l.agent_id)
|
||||
@@ -159,7 +159,7 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang
|
||||
{dialerInfo?.country ? <Flag code={dialerInfo.country} /> : null}
|
||||
<span className="name">{l.dialer_name}</span>
|
||||
{dialerInfo?.isp_key ? <IspIcon ispKey={dialerInfo.isp_key} name={dialerInfo.isp_name} /> : null}
|
||||
<span className="arrow">→</span>
|
||||
<span className="arrow" title="RTT 是往返,下面两行写明每个方向">⇄</span>
|
||||
{agentInfo?.country ? <Flag code={agentInfo.country} /> : null}
|
||||
<span className="name">{l.name}</span>
|
||||
</span>
|
||||
@@ -170,7 +170,7 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang
|
||||
{connectionIssue ? `${connectionIssue} · ${rangeLabel}` : rangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<LineStrips lineId={l.id} range={range} m={l.summary.metrics} />
|
||||
<LineStrips lineId={l.id} dialer={l.dialer_name} listener={l.name} range={range} m={l.summary.metrics} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/links/reset": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Drop every manual link setting and turn automatic linking on: who dials whom follows the default rules again */
|
||||
post: operations["links_reset"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/links/{dialer_id}/{listener_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5891,6 +5908,44 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
links_reset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LinkMatrixDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
links_set: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in new issue
Block a user