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.
This commit is contained in:
justme committed 2026-10-21 16:20:38 +08:00
1 parent e0f6667cd7
commit 48ffa56285
6 files changed
+172 -72

No files matched your search

@@ -9,7 +9,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api; namespace MoShiro.Backend.Api;
/// <summary> /// <summary>
/// 线路开关:哪些(拨号方,监听方)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>(不监听的节点自动拨所有监听节点); /// 线路开关:哪些(拨号方,监听方)要连,一对节点一条线路。自动连接的总开关是设置 <c>fleet.auto_link</c>(不监听的节点拨监听节点;两个都监听时 id 较小的一方拨);
/// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 <c>PUT</c> 一个 <c>enabled: true</c>。 /// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 <c>PUT</c> 一个 <c>enabled: true</c>。
/// </summary> /// </summary>
public static class LinkEndpoints public static class LinkEndpoints
@@ -34,11 +34,21 @@ public static class LinkEndpoints
LinkSnapshot snapshot = await links.SnapshotAsync(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)]; 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>(); 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])
: LinkSnapshot.IsDefaultDialer(nodes[i].Agent, nodes[j].Agent) ? (nodes[i], nodes[j])
: LinkSnapshot.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 +158,7 @@ public static class LinkEndpoints
bool inBackoff = health?.Control.State == ControlState.Backoff; 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, 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(), 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 ? 0 : inBackoff ? health.Control.RetryAttempt : health.Task.Quic.RetryAttempt,
health is null ? null : inBackoff ? health.Control.RetryInMs : health.Task.Quic.RetryInMs); health is null ? null : inBackoff ? health.Control.RetryInMs : health.Task.Quic.RetryInMs);
@@ -11,20 +11,57 @@ public sealed class LinkSnapshot
_overrides = overrides; _overrides = overrides;
} }
/// <summary>自动连接:开着时,所有节点自动拨其他监听节点;关着时,只有手动开启的才连。两个监听节点默认双向互连。</summary> /// <summary>自动连接:开着时,每一对节点自动连成一条线路;关着时,只有手动开启的才连。</summary>
public bool Auto { get; } 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; public bool? OverrideOf(string dialerId, string listenerId) => _overrides.TryGetValue((dialerId, listenerId), out bool value) ? value : null;
/// <summary>这一对最终连不连:手动设置优先,没有就看自动连接。</summary> /// <summary>这一对节点(不分方向)的手动设置:当前方向有设置就用它,否则看反方向。</summary>
public bool IsLinked(AgentRecord dialer, AgentRecord listener) => public bool? PairOverride(string dialerId, string listenerId) => OverrideOf(dialerId, listenerId) ?? OverrideOf(listenerId, dialerId);
dialer.Id != listener.Id && (OverrideOf(dialer.Id.Value, listener.Id.Value) ?? Auto);
/// <summary>
/// 自动连接时这一对由谁拨:只有一方监听,就由不监听的一方拨;两方都监听,由 id 较小的一方拨。
/// 一对节点只有一条线路、一个连接、一份 RTT;上下行都在这条连接上测,不再各拨一次。
/// </summary>
public static bool IsDefaultDialer(AgentRecord dialer, AgentRecord listener) =>
listener.Listens && (!dialer.Listens || 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> /// <summary>
/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:自动连接开着时,所有节点拨其他监听节点(默认), /// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 <see cref="LinkSnapshot.IsDefaultDialer"/>),
/// 关着时只连手动选中的;每一对还可以手动强制开或关,每个方向的线路独立设置。手动设置存在设置表里(<c>link.&lt;拨号方&gt;.&lt;监听方&gt;</c>,值 1 或 0,空表示没设置)。 /// 关着时只连手动选中的;每一对还可以手动强制开或关,并且用 <c>true</c> 选定由哪一方拨。手动设置存在设置表里(<c>link.&lt;拨号方&gt;.&lt;监听方&gt;</c>,值 1 或 0,空表示没设置),一对节点最多保留一条。
/// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。 /// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
/// </summary> /// </summary>
public sealed class LinkService public sealed class LinkService
@@ -54,9 +91,12 @@ public sealed class LinkService
return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides); return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides);
} }
/// <summary>手动设置一对线路:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 取消手动设置。</summary> /// <summary>手动设置一对线路:<c>true</c> 强制连并由 <paramref name="dialerId"/> 拨、<c>false</c> 强制不连、<c>null</c> 取消手动设置。反方向的旧设置同时清掉。</summary>
public ValueTask SetAsync(string dialerId, string listenerId, bool? enabled, CancellationToken ct) => public async ValueTask SetAsync(string dialerId, string listenerId, bool? enabled, CancellationToken ct)
_settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct); {
await _settings.SetAsync($"{Prefix}{listenerId}.{dialerId}", "", ct);
await _settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
}
/// <summary>节点被删除:清掉所有涉及它的手动设置。</summary> /// <summary>节点被删除:清掉所有涉及它的手动设置。</summary>
public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct) public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct)
@@ -26,24 +26,38 @@ 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); (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] [Fact]
public async Task ListeningAgents_AutomaticallyLinkBothDirections_WithoutSelfLinks() public async Task ListeningAgents_AutomaticallyShareOneLine_DialedBySmallerId()
{ {
await using AgentSimulator first = await _backend.ConnectAgentAsync(); await using AgentSimulator first = await _backend.ConnectAgentAsync();
await using AgentSimulator second = await _backend.ConnectAgentAsync(); await using AgentSimulator second = await _backend.ConnectAgentAsync();
string a = first.NodeId.Value; string[] ids = [.. new[] { first.NodeId.Value, second.NodeId.Value }.Order(StringComparer.Ordinal)];
string b = second.NodeId.Value; (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(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)); Assert.Null(await LinkAsync(a, a));
using HttpResponseMessage self = await SetLinkAsync(a, a, true); using HttpResponseMessage self = await SetLinkAsync(a, a, true);
Assert.Equal(HttpStatusCode.UnprocessableEntity, self.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, self.StatusCode);
await first.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == b)); await listener.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == a));
await second.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); // 手动让 b 拨 a:线路换方向,仍然只有一条
off.EnsureSuccessStatusCode(); using HttpResponseMessage swap = await SetLinkAsync(b, a, true);
Assert.False((await LinkAsync(a, b))!["linked"]!.GetValue<bool>()); swap.EnsureSuccessStatusCode();
Assert.Null(await LinkAsync(a, b));
Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue<bool>()); 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>());
} }
[Fact] [Fact]
+25 -17
View File
@@ -375,15 +375,30 @@
} }
.link-board-edge.connected, .link-board-edge.connected,
.link-board-legend .connected { .link-board-edge.good,
.link-board-legend .good {
color: var(--app-ok); 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-edge.pending,
.link-board-legend .pending { .link-board-legend .pending {
color: var(--app-warn); color: var(--app-warn);
} }
.link-board-edge.dim {
opacity: 0.2;
}
.link-board-edge.selected, .link-board-edge.selected,
.link-board-edge:focus-visible { .link-board-edge:focus-visible {
color: var(--app-accent); color: var(--app-accent);
@@ -400,6 +415,11 @@
stroke-dasharray: 6 6; 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.selected .link-board-edge-line,
.link-board-edge:hover .link-board-edge-line, .link-board-edge:hover .link-board-edge-line,
.link-board-edge:focus-visible .link-board-edge-line { .link-board-edge:focus-visible .link-board-edge-line {
@@ -413,22 +433,6 @@
pointer-events: stroke; 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 { .link-board-edge-label rect {
fill: var(--app-card); fill: var(--app-card);
stroke: var(--app-outline-soft); stroke: var(--app-outline-soft);
@@ -563,6 +567,10 @@
border-top-style: dashed; border-top-style: dashed;
} }
.link-board-legend .pending::before {
border-top-style: dotted;
}
.link-board-empty { .link-board-empty {
position: absolute; position: absolute;
inset: 0; inset: 0;
+50 -25
View File
@@ -1,5 +1,5 @@
import { Maximize, Minus, Plus, RotateCcw, Server } from 'lucide-react' 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 { Link } from 'react-router-dom'
import type { LinkDto, LinkMatrixDto } from '@shared/api/types' import type { LinkDto, LinkMatrixDto } from '@shared/api/types'
import { getBase } from '@shared/api/source' import { getBase } from '@shared/api/source'
@@ -13,6 +13,10 @@ type Drag = { pointer: number; origin: Point; start: Point; node?: string }
const WIDTH = 216 const WIDTH = 216
const HEIGHT = 108 const HEIGHT = 108
const keyOf = (link: LinkDto) => `${link.dialer_id}/${link.listener_id}` 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)) const clampScale = (scale: number) => Math.max(0.25, Math.min(2, scale))
function labelOf(link: LinkDto) { function labelOf(link: LinkDto) {
@@ -26,6 +30,24 @@ function labelOf(link: LinkDto) {
return states[link.connection_state] ?? '连接中' 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 { function readPositions(key: string): Positions {
try { try {
const saved: unknown = JSON.parse(localStorage.getItem(key) ?? '{}') 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 data: LinkMatrixDto
quality: Record<string, LineQuality>
busy: boolean busy: boolean
onSet: (link: LinkDto, enabled: boolean | null) => Promise<void> onSet: (link: LinkDto, enabled: boolean | null) => Promise<void>
onRetry: (link: LinkDto) => 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 [view, setView] = useState<View>({ x: 0, y: 0, scale: 1 })
const [size, setSize] = useState({ width: 0, height: 0 }) const [size, setSize] = useState({ width: 0, height: 0 })
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const [focusNode, setFocusNode] = useState<string | null>(null)
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const viewport = useRef<HTMLDivElement>(null) const viewport = useRef<HTMLDivElement>(null)
const drag = useRef<Drag | null>(null) const drag = useRef<Drag | null>(null)
const initialized = useRef(false) const initialized = useRef(false)
const marker = useId().replaceAll(':', '')
const nodes = data.nodes const nodes = data.nodes
const focused = data.items.find((link) => keyOf(link) === selected) const focused = data.items.find((link) => keyOf(link) === selected)
const focusedQuality = focused ? quality[lineIdOf(focused)] : undefined
const nodeIds = nodes.map((node) => node.id).join(',') const nodeIds = nodes.map((node) => node.id).join(',')
function pointOf(id: string, layout = positions): Point { 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 if (event.button !== 0 || drag.current || (event.target as Element).closest('a, button, [data-edge]')) return
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
setFocusNode(node ?? null)
viewport.current?.setPointerCapture(event.pointerId) viewport.current?.setPointerCapture(event.pointerId)
drag.current = { pointer: event.pointerId, origin: { x: event.clientX, y: event.clientY }, start: node ? pointOf(node) : view, node } drag.current = { pointer: event.pointerId, origin: { x: event.clientX, y: event.clientY }, start: node ? pointOf(node) : view, node }
setDragging(true) setDragging(true)
@@ -156,7 +181,7 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
<div className="link-board-layout"> <div className="link-board-layout">
<section className="link-board" aria-label="Agent 节点画板"> <section className="link-board" aria-label="Agent 节点画板">
<div className="link-board-tools"> <div className="link-board-tools">
<span className="muted">拖动节点排布 · 拖动空白平移 · 滚轮缩放</span> <span className="muted">一对节点一条线,线上是近 3 小时 RTT 中位数 · 点节点只看它的线路 · 拖动排布 · 滚轮缩放</span>
<div className="row"> <div className="row">
<button type="button" className="btn small" aria-label="缩小画板" onClick={() => zoom(1 / 1.2)}><Minus size={16} /></button> <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> <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}> <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} {!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})` }}> <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="有向线路"> <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>
{data.items.map((link) => { {data.items.map((link) => {
const source = pointOf(link.dialer_id) const source = pointOf(link.dialer_id)
const target = pointOf(link.listener_id) const target = pointOf(link.listener_id)
@@ -179,25 +203,26 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
const ux = dx / length const ux = dx / length
const uy = dy / 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 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 startX = source.x + WIDTH / 2 + ux * margin
const startY = source.y + HEIGHT / 2 + uy * margin const startY = source.y + HEIGHT / 2 + uy * margin
const endX = target.x + WIDTH / 2 - ux * margin const endX = target.x + WIDTH / 2 - ux * margin
const endY = target.y + HEIGHT / 2 - uy * margin const endY = target.y + HEIGHT / 2 - uy * margin
const controlX = (startX + endX) / 2 - uy * bend const path = `M ${startX} ${startY} L ${endX} ${endY}`
const controlY = (startY + endY) / 2 + ux * bend const labelX = (startX + endX) / 2
const path = `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}` const labelY = (startY + endY) / 2
const labelX = (startX + 2 * controlX + endX) / 4
const labelY = (startY + 2 * controlY + endY) / 4
const active = selected === keyOf(link) 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 ( 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)) } }}> <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)}</title> <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-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})`}> <g className="link-board-edge-label" transform={`translate(${labelX}, ${labelY})`}>
<rect x="-52" y="-13" width="104" height="26" rx="13" /> <rect x="-40" y="-12" width="80" height="24" rx="12" />
<text textAnchor="middle" dominantBaseline="central">{labelOf(link)}</text> <text textAnchor="middle" dominantBaseline="central">{text}</text>
</g> </g>
</g> </g>
) )
@@ -220,29 +245,29 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
) )
})} })}
</div> </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> </div>
</section> </section>
<aside className="link-board-inspector" aria-label="线路操作"> <aside className="link-board-inspector" aria-label="线路操作">
<div className="card-title">线路详情</div> <div className="card-title">线路详情</div>
<select className="input" aria-label="选择线路" value={selected ?? ''} onChange={(event) => setSelected(event.target.value || null)}> <select className="input" aria-label="选择线路" value={selected ?? ''} onChange={(event) => setSelected(event.target.value || null)}>
<option value="">选择来源 → 目标</option> <option value="">选择一条线路</option>
{data.items.map((link) => <option key={keyOf(link)} value={keyOf(link)}>{link.dialer_name} → {link.listener_name}</option>)} {data.items.map((link) => <option key={keyOf(link)} value={keyOf(link)}>{link.dialer_name} — {link.listener_name}</option>)}
</select> </select>
{focused ? ( {focused ? (
<div className="stack"> <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> <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.connection_reason ? <p className="muted">{focused.connection_reason}</p> : null}
{focused.last_error ? <p className="notice warn">{focused.last_error}</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} {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 || focused.override == null} onClick={() => onSet(focused, null)}>恢复自动</button>
<button type="button" className="btn" disabled={busy} onClick={() => onRetry(focused)}>立即重试</button> <button type="button" className="btn" disabled={busy} onClick={() => onRetry(focused)}>立即重试</button>
<span className="faint">操作只影响当前方向。</span> <span className="faint">一对节点只有一条线路,上下行都在这条连接上测,RTT 只测一次。</span>
</div> </div>
) : <Empty>点击画板中的连线,查看状态或调整测量方向。</Empty>} ) : <Empty>点击画板中的连线,查看线路质量或开关这条线路。</Empty>}
<p className="link-board-storage faint">节点位置仅保存在当前浏览器。</p> <p className="link-board-storage faint">节点位置仅保存在当前浏览器。</p>
</aside> </aside>
</div> </div>
+9 -6
View File
@@ -1,13 +1,13 @@
import { LinkBoard } from './LinkBoard' import { LinkBoard, type LineQuality } from './LinkBoard'
import { api } from '@shared/api/client' 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 { useQuery } from '@shared/hooks'
import { runtimeStatusLabel } from '@shared/nodes' import { runtimeStatusLabel } from '@shared/nodes'
import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui' import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui'
/** /**
* 线路:Agent 之间的有向线路。自动连接开着时所有可达 Agent 相互连接,关着时只连手动选中的; * 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的;
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。来源 Agent 始终是主动拨出的一方。 * 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;都监听时 id 较小的拨)。
*/ */
/** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */ /** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */
const CONNECTION_LABELS: Record<string, string> = { const CONNECTION_LABELS: Record<string, string> = {
@@ -20,6 +20,9 @@ const CONNECTION_LABELS: Record<string, string> = {
export function Links({ dialerId }: { dialerId?: string }) { export function Links({ dialerId }: { dialerId?: string }) {
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000) 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 { busy, run } = useAction()
const items = matrix.data?.items ?? [] const items = matrix.data?.items ?? []
const dialers = (matrix.data?.nodes ?? []).filter((n) => !dialerId || n.id === dialerId).map((n) => [n.id, n.name] as const) const dialers = (matrix.data?.nodes ?? []).filter((n) => !dialerId || n.id === dialerId).map((n) => [n.id, n.name] as const)
@@ -43,14 +46,14 @@ export function Links({ dialerId }: { dialerId?: string }) {
<div className={`row${dialerId ? '' : ' link-board-header'}`} style={{ marginBottom: 12 }}> <div className={`row${dialerId ? '' : ' link-board-header'}`} style={{ marginBottom: 12 }}>
<b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b> <b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b>
{matrix.loading && !matrix.data ? <Spinner /> : null} {matrix.loading && !matrix.data ? <Spinner /> : null}
<span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : 'Agent 默认相互探测,点击连线管理方向。'}</span> <span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'}</span>
{!dialerId ? <label className="switch right"> {!dialerId ? <label className="switch right">
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} /> <input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
自动连接 {matrix.data?.auto_link === false ? '关' : '开'} 自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
</label> : null} </label> : null}
</div> </div>
<ErrorNotice error={matrix.error} /> <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 && matrix.data && items.length === 0 ? <Empty>尚无可连接的线路,请添加并启用其他 Agent。</Empty> : null}
{dialerId ? <table className="table"> {dialerId ? <table className="table">
<thead> <thead>