diff --git a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs index de1c341..2ecea30 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs @@ -9,7 +9,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Api; /// -/// 线路开关:哪些(拨号方,监听方)要连。自动连接的总开关是设置 fleet.auto_link(不监听的节点自动拨所有监听节点); +/// 线路开关:哪些(拨号方,监听方)要连,一对节点一条线路。自动连接的总开关是设置 fleet.auto_link(不监听的节点拨监听节点;两个都监听时 id 较小的一方拨); /// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 PUT 一个 enabled: true。 /// public static class LinkEndpoints @@ -34,11 +34,21 @@ public static class LinkEndpoints 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(); - 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; 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); diff --git a/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs b/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs index d973dd5..f261b1f 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs @@ -11,20 +11,57 @@ public sealed class LinkSnapshot _overrides = overrides; } - /// 自动连接:开着时,所有节点自动拨其他监听节点;关着时,只有手动开启的才连。两个监听节点默认双向互连。 + /// 自动连接:开着时,每一对节点自动连成一条线路;关着时,只有手动开启的才连。 public bool Auto { get; } - /// 手动设置:true 强制连、false 强制不连、null 没设置(跟随自动连接)。 + /// 手动设置:true 强制连、false 强制不连、null 没设置(跟随自动连接)。只看这一个方向。 public bool? OverrideOf(string dialerId, string listenerId) => _overrides.TryGetValue((dialerId, listenerId), out bool value) ? value : null; - /// 这一对最终连不连:手动设置优先,没有就看自动连接。 - public bool IsLinked(AgentRecord dialer, AgentRecord listener) => - dialer.Id != listener.Id && (OverrideOf(dialer.Id.Value, listener.Id.Value) ?? Auto); + /// 这一对节点(不分方向)的手动设置:当前方向有设置就用它,否则看反方向。 + public bool? PairOverride(string dialerId, string listenerId) => OverrideOf(dialerId, listenerId) ?? OverrideOf(listenerId, dialerId); + + /// + /// 自动连接时这一对由谁拨:只有一方监听,就由不监听的一方拨;两方都监听,由 id 较小的一方拨。 + /// 一对节点只有一条线路、一个连接、一份 RTT;上下行都在这条连接上测,不再各拨一次。 + /// + public static bool IsDefaultDialer(AgentRecord dialer, AgentRecord listener) => + listener.Listens && (!dialer.Listens || string.CompareOrdinal(dialer.Id.Value, listener.Id.Value) < 0); + + /// + /// 这一对最终连不连、是不是由这个方向拨:手动设置优先(true 的方向就是拨号方,两个方向都设了 true 时按默认方向), + /// 对端不监听时反方向拨不了;有 false 就整对不连;没有设置就看自动连接和默认方向。 + /// + 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); + } } /// -/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:自动连接开着时,所有节点拨其他监听节点(默认), -/// 关着时只连手动选中的;每一对还可以手动强制开或关,每个方向的线路独立设置。手动设置存在设置表里(link.<拨号方>.<监听方>,值 1 或 0,空表示没设置)。 +/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:每一对节点只有一条线路,自动连接开着时都连(谁拨见 ), +/// 关着时只连手动选中的;每一对还可以手动强制开或关,并且用 true 选定由哪一方拨。手动设置存在设置表里(link.<拨号方>.<监听方>,值 1 或 0,空表示没设置),一对节点最多保留一条。 /// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(peers)和线路目录里;改动之后要调用 重新下发。 /// public sealed class LinkService @@ -54,9 +91,12 @@ public sealed class LinkService return new LinkSnapshot(all.GetValueOrDefault(SettingsService.AutoLink) != "false", overrides); } - /// 手动设置一对线路:true 强制连、false 强制不连、null 取消手动设置。 - public ValueTask SetAsync(string dialerId, string listenerId, bool? enabled, CancellationToken ct) => - _settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct); + /// 手动设置一对线路:true 强制连并由 拨、false 强制不连、null 取消手动设置。反方向的旧设置同时清掉。 + 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); + } /// 节点被删除:清掉所有涉及它的手动设置。 public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct) diff --git a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs index f48dbf8..5b4d846 100644 --- a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs @@ -26,24 +26,38 @@ public sealed class LinkTests : IClassFixture (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["dialer_id"]!.GetValue() == dialer && i["listener_id"]!.GetValue() == 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()); - Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue()); + 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()); + // 手动让 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()); + 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()); + Assert.False(closed["override"]!.GetValue()); + await SetLinkAsync(b, a, null); + Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue()); } [Fact] diff --git a/web/admin/src/admin.css b/web/admin/src/admin.css index b4b5730..1933a3d 100644 --- a/web/admin/src/admin.css +++ b/web/admin/src/admin.css @@ -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; diff --git a/web/admin/src/pages/LinkBoard.tsx b/web/admin/src/pages/LinkBoard.tsx index 85662ad..a8d8c16 100644 --- a/web/admin/src/pages/LinkBoard.tsx +++ b/web/admin/src/pages/LinkBoard.tsx @@ -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 busy: boolean onSet: (link: LinkDto, enabled: boolean | null) => Promise onRetry: (link: LinkDto) => Promise @@ -47,13 +70,14 @@ export function LinkBoard({ data, busy, onSet, onRetry }: { const [view, setView] = useState({ x: 0, y: 0, scale: 1 }) const [size, setSize] = useState({ width: 0, height: 0 }) const [selected, setSelected] = useState(null) + const [focusNode, setFocusNode] = useState(null) const [dragging, setDragging] = useState(false) const viewport = useRef(null) const drag = useRef(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 }: {
- 拖动节点排布 · 拖动空白平移 · 滚轮缩放 + 一对节点一条线,线上是近 3 小时 RTT 中位数 · 点节点只看它的线路 · 拖动排布 · 滚轮缩放
{Math.round(view.scale * 100)}% @@ -168,8 +193,7 @@ export function LinkBoard({ data, busy, onSet, onRetry }: {
start(event)} onPointerMove={move} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={end}> {!nodes.length ?
还没有已启用的 Agent,请先添加并启用节点。
: null}
- - {['connected', 'pending', 'disabled', 'selected'].map((tone) => )} + {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 ( - setSelected(keyOf(link))} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(keyOf(link)) } }}> - {link.dialer_name} → {link.listener_name}:{labelOf(link)} + setSelected(keyOf(link))} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(keyOf(link)) } }}> + {link.dialer_name} 拨 {link.listener_name}:{labelOf(link)}{q?.rtt != null ? `,RTT ${Math.round(q.rtt)} ms` : ''} - + - - {labelOf(link)} + + {text} ) @@ -220,29 +245,29 @@ export function LinkBoard({ data, busy, onSet, onRetry }: { ) })}
-
已连接连接中已关闭
+
RTT < 100 ms100–250 ms 或丢包 ≥ 1%> 250 ms 或丢包 ≥ 5%连接中已关闭
diff --git a/web/admin/src/pages/Links.tsx b/web/admin/src/pages/Links.tsx index d110227..3c97f4e 100644 --- a/web/admin/src/pages/Links.tsx +++ b/web/admin/src/pages/Links.tsx @@ -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 = { @@ -20,6 +20,9 @@ const CONNECTION_LABELS: Record = { export function Links({ dialerId }: { dialerId?: string }) { const matrix = useQuery(() => api.get('/api/admin/links'), [], 8000) + // 线路质量:画板上每条线写近 3 小时的 RTT 中位数和丢包,没有数据就只显示连接状态 + const overview = useQuery(() => (dialerId ? Promise.resolve(null) : api.get('/api/public/overview', { range: '3h', metrics: 'rtt.p50,loss.total', ip_version: 'ipv4' })), [dialerId], 30_000) + const quality: Record = 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) @@ -43,14 +46,14 @@ export function Links({ dialerId }: { dialerId?: string }) {
{dialerId ? '拨出连接' : 'Agent 节点画板'} {matrix.loading && !matrix.data ? : null} - {dialerId ? '点格子调整单方向的连接策略。' : 'Agent 默认相互探测,点击连线管理方向。'} + {dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'} {!dialerId ? : null}
- {!dialerId && matrix.data ? : null} + {!dialerId && matrix.data ? : null} {dialerId && matrix.data && items.length === 0 ? 尚无可连接的线路,请添加并启用其他 Agent。 : null} {dialerId ?