5 Commits
Author SHA1 Message Date
justme 7fe9775f97 web: reset links to the default mode, and keep CN agents dial-only by default [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful
POST /api/admin/links/reset clears every manual link setting and turns automatic linking on. In the default rules a listening node in CN ranks below a listening node elsewhere, so the CN side dials; the links are pushed again when a node's country is first resolved or changes.
2026-10-21 16:35:58 +08:00
justme 35207a70ea web: group the line card rows by direction so each agent pair is named once 2026-10-21 16:31:22 +08:00
justme cfe6571db5 web: name the two agents on each direction row of the line cards 2026-10-21 16:29:56 +08:00
justme 9f5a6cf77a analysis: split TCP retransmissions into forward and return, and show both on the line cards
tcp.retrans.up counts retransmitted segments sent by the dialing side, tcp.retrans.down those sent by the listening side. The cards show RTT as a round trip (⇄) with forward (→) and return (←) rows for QUIC loss and TCP retransmission.
2026-10-21 16:26:34 +08:00
justme 48ffa56285 web: one line per node pair and RTT/loss on the link board
A pair of nodes now has a single line, dialed by the non-listening side or by the smaller id when both listen, so RTT is measured once. The board draws it undirected and colours it by RTT and loss; a manual setting applies to the pair.
2026-10-21 16:20:38 +08:00
20 changed files with 525 additions and 162 deletions

No files matched your search

+42
View File
@@ -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.&lt;拨号方&gt;.&lt;监听方&gt;</c>,值 1 或 0,空表示没设置)。
/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 <see cref="LinkSnapshot.IsDefaultDialer"/>,国内节点默认只拨出),
/// 关着时只连手动选中的;每一对还可以手动强制开或关,并且用 <c>true</c> 选定由哪一方拨。手动设置存在设置表里(<c>link.&lt;拨号方&gt;.&lt;监听方&gt;</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
View File
@@ -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;
+50 -25
View File
@@ -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 &lt; 100 ms</span><span className="fair">100–250 ms 或丢包 ≥ 1%</span><span className="poor">&gt; 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>
+16 -7
View File
@@ -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>
+40 -43
View File
@@ -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
View File
@@ -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;
+8 -6
View File
@@ -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' },
+7 -6
View File
@@ -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">
+4 -4
View File
@@ -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>
)
}
+55
View File
@@ -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;