From f62eefd365151b5b25edde611149c26105ce8328 Mon Sep 17 00:00:00 2001 From: justme Date: Thu, 22 Oct 2026 05:59:56 +0800 Subject: [PATCH] web: manage links in a master-detail explorer instead of the board --- web/admin/src/admin.css | 521 ++++++++++++--------------- web/admin/src/pages/LinkBoard.tsx | 275 -------------- web/admin/src/pages/LinkExplorer.tsx | 193 ++++++++++ web/admin/src/pages/Links.tsx | 96 +---- web/admin/src/pages/lineQuality.ts | 16 + 5 files changed, 456 insertions(+), 645 deletions(-) delete mode 100644 web/admin/src/pages/LinkBoard.tsx create mode 100644 web/admin/src/pages/LinkExplorer.tsx create mode 100644 web/admin/src/pages/lineQuality.ts diff --git a/web/admin/src/admin.css b/web/admin/src/admin.css index 1933a3d..b6d3b38 100644 --- a/web/admin/src/admin.css +++ b/web/admin/src/admin.css @@ -304,291 +304,6 @@ } } -/* Agent 节点画板:节点使用世界坐标,缩放和平移只作用于画板内容。 */ -.link-board-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 284px; - gap: 16px; -} - -.link-board { - min-width: 0; - overflow: hidden; - border: 1px solid var(--app-outline-soft); - border-radius: 20px; - background: var(--app-bg); -} - -.link-board-tools { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 10px; - padding: 12px 16px; - border-bottom: 1px solid var(--app-outline-soft); - background: var(--app-card); - font-size: 12px; -} - -.link-board-scale { - min-width: 42px; - text-align: center; - font-variant-numeric: tabular-nums; - color: var(--app-ink-muted); -} - -.link-board-viewport { - position: relative; - height: clamp(440px, calc(100dvh - 230px), 1000px); - overflow: hidden; - cursor: grab; - touch-action: none; - user-select: none; - background-image: radial-gradient(var(--app-outline) 1px, transparent 1px); - background-size: 22px 22px; -} - -.link-board-viewport.dragging, -.link-board-viewport.dragging .link-board-node { - cursor: grabbing; -} - -.link-board-world { - position: absolute; - width: 1px; - height: 1px; - transform-origin: 0 0; -} - -.link-board-edges { - position: absolute; - overflow: visible; - pointer-events: none; -} - -.link-board-edge { - cursor: pointer; - outline: none; - pointer-events: visiblePainted; - color: var(--app-ink-faint); -} - -.link-board-edge.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); -} - -.link-board-edge-line { - stroke: currentColor; - stroke-width: 2; - fill: none; - pointer-events: none; -} - -.link-board-edge.disabled .link-board-edge-line { - 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 { - stroke-width: 3; -} - -.link-board-edge-hit { - stroke: transparent; - stroke-width: 22; - fill: none; - pointer-events: stroke; -} - -.link-board-edge-label rect { - fill: var(--app-card); - stroke: var(--app-outline-soft); -} - -.link-board-edge.selected .link-board-edge-label rect, -.link-board-edge:focus-visible .link-board-edge-label rect { - fill: var(--app-accent-container); - stroke: var(--app-accent); -} - -.link-board-edge-label text { - fill: currentColor; - font-size: 12px; - font-weight: 500; -} - -.link-board-node { - position: absolute; - display: flex; - flex-direction: column; - gap: 10px; - width: 216px; - height: 108px; - padding: 14px 16px; - border: 1px solid var(--app-outline); - border-radius: 18px; - background: var(--app-card); - box-shadow: 0 5px 18px color-mix(in srgb, var(--app-shadow) 7%, transparent); - cursor: grab; -} - -.link-board-node:focus-visible { - outline: 2px solid var(--app-accent); - outline-offset: 4px; -} - -.link-board-node.offline { - border-style: dashed; -} - -.link-board-node-head { - display: flex; - align-items: center; - gap: 8px; -} - -.link-board-node-head > svg { - flex-shrink: 0; - color: var(--app-accent); -} - -.link-board-node-head strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.link-board-detail { - margin-left: auto; - flex-shrink: 0; - font-size: 12px; - cursor: pointer; -} - -.link-board-node-counts { - display: flex; - justify-content: space-between; - gap: 8px; - color: var(--app-ink-muted); - font-size: 11px; -} - -.link-board-node-counts b { - color: var(--app-ink); -} - -.link-board-inspector { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 18px; - min-width: 0; - padding: 16px; - border-radius: 20px; - background: var(--app-card-inset); -} - -.link-board-inspector .input { - width: 100%; -} - -.link-board-route { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 10px; - font-weight: 600; - overflow-wrap: anywhere; -} - -.link-board-storage { - margin-top: auto; - padding-top: 16px; - font-size: 12px; -} - -.link-board-legend { - position: absolute; - bottom: 14px; - left: 16px; - display: flex; - flex-wrap: wrap; - gap: 16px; - padding: 8px 12px; - border-radius: var(--r-pill); - background: var(--app-card); - pointer-events: none; - font-size: 11px; -} - -.link-board-legend span::before { - display: inline-block; - width: 16px; - margin-right: 6px; - border-top: 2px solid currentColor; - vertical-align: middle; - content: ''; -} - -.link-board-legend .disabled::before { - border-top-style: dashed; -} - -.link-board-legend .pending::before { - border-top-style: dotted; -} - -.link-board-empty { - position: absolute; - inset: 0; - display: grid; - place-items: center; - pointer-events: none; -} - -@media (max-width: 1000px) { - .link-board-layout { - grid-template-columns: minmax(0, 1fr); - } - - .link-board-viewport { - height: 480px; - } -} - .link-board-header { flex-wrap: wrap; } @@ -597,7 +312,239 @@ white-space: nowrap; } -.link-board-tools > .row { - flex-wrap: wrap; +/* 线路管理:左边 Agent 列表(健康摘要,有问题的在前),右边是选中 Agent 的全部线路 */ +.link-dot { + display: inline-block; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--app-ink-faint); +} + +.link-dot.good { + background: var(--app-ok); +} + +.link-dot.fair { + background: var(--app-warn); +} + +.link-dot.poor { + background: var(--app-bad); +} + +.link-explorer { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.link-explorer-list { + display: grid; + gap: 8px; + padding: 10px; + border-radius: var(--r-control); + background: var(--app-card); +} + +.link-explorer-filter { + display: grid; gap: 8px; } + +.link-explorer-list ul { + display: grid; + max-height: 560px; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.link-explorer-agent { + display: flex; + width: 100%; + align-items: center; + gap: 8px; + padding: 7px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--app-ink); + cursor: pointer; + font: inherit; + font-size: 13px; + text-align: left; +} + +.link-explorer-agent:hover { + background: var(--app-card-inset); +} + +.link-explorer-agent.selected { + background: var(--app-accent-container); + color: var(--app-accent); +} + +.link-explorer-agent.offline .name { + color: var(--app-ink-faint); +} + +.link-explorer-agent .name { + min-width: 0; + flex: 1; + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.link-explorer-agent small { + color: var(--app-ink-faint); + font-size: 10px; +} + +.link-explorer-agent .count { + color: var(--app-ink-faint); + font-size: 11px; +} + +.link-explorer-agent .badge { + min-width: 18px; + padding: 1px 6px; + border-radius: var(--r-pill); + font-size: 11px; + font-weight: 700; + text-align: center; +} + +.link-explorer-agent .badge.poor { + background: color-mix(in srgb, var(--app-bad) 30%, transparent); + color: var(--app-bad); +} + +.link-explorer-agent .badge.fair { + background: color-mix(in srgb, var(--app-warn) 26%, transparent); + color: var(--app-warn); +} + +.link-explorer-list .empty { + padding: 8px; + font-size: 12px; +} + +.link-explorer-hint { + margin-left: 6px; + color: var(--app-ink-faint); + cursor: help; +} + +.link-explorer-detail { + min-width: 0; +} + +.link-explorer-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} + +.link-explorer-head .input { + width: auto; + margin-left: 6px; +} + +.link-explorer-peer { + padding: 0; + border: 0; + background: transparent; + color: var(--app-ink); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.link-explorer-peer:hover { + color: var(--app-accent); +} + +.link-explorer-lines td { + vertical-align: middle; +} + +.link-explorer-lines tr.poor { + background: color-mix(in srgb, var(--app-bad) 8%, transparent); +} + +.link-explorer-lines tr.fair, +.link-explorer-lines tr.pending { + background: color-mix(in srgb, var(--app-warn) 7%, transparent); +} + +.link-explorer-lines tr.disabled { + opacity: 0.6; +} + +.warn-text { + color: var(--app-warn); + font-weight: 600; +} + +@media (width <= 900px) { + .link-explorer { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Agent 详情:顶部概览条、线路表、折叠的 IPv6 诊断 */ +.agent-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + padding: 14px 16px; + border-radius: var(--r-control); + background: var(--app-card); +} + +.agent-summary > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.agent-summary span { + color: var(--app-ink-faint); + font-size: 11px; +} + +.agent-summary b { + overflow: hidden; + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-lines td { + vertical-align: middle; +} + +.agent-ipv6 { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--app-outline-soft); +} + +.agent-ipv6 > summary { + color: var(--app-ink-muted); + cursor: pointer; + font-size: 13px; +} + +.agent-ipv6 > .kv { + margin-top: 8px; +} diff --git a/web/admin/src/pages/LinkBoard.tsx b/web/admin/src/pages/LinkBoard.tsx deleted file mode 100644 index a8d8c16..0000000 --- a/web/admin/src/pages/LinkBoard.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { Maximize, Minus, Plus, RotateCcw, Server } from 'lucide-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' -import { runtimeStatusLabel, RuntimeStatusChip } from '@shared/nodes' -import { Chip, Empty } from '@shared/ui' - -type Point = { x: number; y: number } -type View = Point & { scale: number } -type Positions = Record -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) { - if (!link.linked) return '已关闭' - if (link.connected) return '已连接' - const states: Record = { - waiting_report: '等待上报', control_connecting: '控制连接中', control_down: '控制连接断开', - task_connecting: 'QUIC 连接中', task_retrying: 'QUIC 重试中', - dialer_unavailable: '来源不可用', listener_unavailable: '目标不可用', - } - 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) ?? '{}') - if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return {} - return Object.fromEntries(Object.entries(saved).filter(([, p]) => p && typeof p === 'object' && Number.isFinite(p.x) && Number.isFinite(p.y))) - } catch { - return {} - } -} - -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 -}) { - const storageKey = `moshiro.link-board:${getBase() || window.location.origin}` - const [positions, setPositions] = useState(() => readPositions(storageKey)) - 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 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 { - if (layout[id]) return layout[id] - const index = nodes.findIndex((node) => node.id === id) - const angle = Math.PI + index * Math.PI * 2 / Math.max(1, nodes.length) - const radius = nodes.length > 1 ? Math.max(250, nodes.length * 45) : 0 - return { x: Math.cos(angle) * radius - WIDTH / 2, y: Math.sin(angle) * radius - HEIGHT / 2 } - } - - function fit(layout = positions) { - if (!nodes.length || !size.width || !size.height) return - const points = nodes.map((node) => pointOf(node.id, layout)) - const left = Math.min(...points.map((p) => p.x)) - 64 - const top = Math.min(...points.map((p) => p.y)) - 88 - const width = Math.max(...points.map((p) => p.x + WIDTH)) + 64 - left - const height = Math.max(...points.map((p) => p.y + HEIGHT)) + 88 - top - const scale = Math.min(1, clampScale(Math.min(size.width / width, size.height / height))) - setView({ x: (size.width - width * scale) / 2 - left * scale, y: (size.height - height * scale) / 2 - top * scale, scale }) - } - - useEffect(() => { - const element = viewport.current - if (!element) return - const observer = new ResizeObserver(([entry]) => setSize({ width: entry.contentRect.width, height: entry.contentRect.height })) - observer.observe(element) - return () => observer.disconnect() - }, []) - - useEffect(() => { - if (!initialized.current && size.width && size.height && nodes.length) { - fit() - initialized.current = true - } - }, [size.width, size.height, nodeIds]) - - useEffect(() => { - const timer = window.setTimeout(() => { - try { localStorage.setItem(storageKey, JSON.stringify(positions)) } catch { /* 浏览器禁用存储时继续使用当前布局。 */ } - }, 250) - return () => window.clearTimeout(timer) - }, [positions, storageKey]) - - useEffect(() => { - const element = viewport.current - if (!element) return - const wheel = (event: WheelEvent) => { - event.preventDefault() - if (drag.current) return - const rect = element.getBoundingClientRect() - const x = event.clientX - rect.left - const y = event.clientY - rect.top - setView((current) => { - const scale = clampScale(current.scale * Math.exp(-event.deltaY * 0.0015)) - const ratio = scale / current.scale - return { x: x - (x - current.x) * ratio, y: y - (y - current.y) * ratio, scale } - }) - } - element.addEventListener('wheel', wheel, { passive: false }) - return () => element.removeEventListener('wheel', wheel) - }, []) - - function start(event: PointerEvent, node?: string) { - 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) - } - - function move(event: PointerEvent) { - const current = drag.current - if (!current || current.pointer !== event.pointerId) return - const x = event.clientX - current.origin.x - const y = event.clientY - current.origin.y - if (current.node) { - setPositions((layout) => ({ ...layout, [current.node!]: { x: current.start.x + x / view.scale, y: current.start.y + y / view.scale } })) - } else { - setView((old) => ({ ...old, x: current.start.x + x, y: current.start.y + y })) - } - } - - function end(event: PointerEvent) { - if (drag.current?.pointer !== event.pointerId) return - drag.current = null - setDragging(false) - if (viewport.current?.hasPointerCapture(event.pointerId)) viewport.current.releasePointerCapture(event.pointerId) - } - - function zoom(factor: number) { - setView((current) => { - const scale = clampScale(current.scale * factor) - const ratio = scale / current.scale - return { x: size.width / 2 - (size.width / 2 - current.x) * ratio, y: size.height / 2 - (size.height / 2 - current.y) * ratio, scale } - }) - } - - return ( -
-
-
- 一对节点一条线,线上是近 3 小时 RTT 中位数 · 点节点只看它的线路 · 拖动排布 · 滚轮缩放 -
- - {Math.round(view.scale * 100)}% - - - -
-
-
start(event)} onPointerMove={move} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={end}> - {!nodes.length ?
还没有已启用的 Agent,请先添加并启用节点。
: null} -
- - {data.items.map((link) => { - const source = pointOf(link.dialer_id) - const target = pointOf(link.listener_id) - const dx = target.x - source.x - const dy = target.y - source.y - const length = Math.hypot(dx, dy) || 1 - 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 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 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)}{q?.rtt != null ? `,RTT ${Math.round(q.rtt)} ms` : ''} - - - - - {text} - - - ) - })} - - {nodes.map((node) => { - const point = pointOf(node.id) - const outgoing = data.items.filter((link) => link.dialer_id === node.id && link.connected).length - const incoming = data.items.filter((link) => link.listener_id === node.id && link.connected).length - return ( -
start(event, node.id)} onKeyDown={(event) => { - if (event.target !== event.currentTarget || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return - event.preventDefault() - setPositions((layout) => ({ ...layout, [node.id]: { x: point.x + (event.key === 'ArrowRight' ? 20 : event.key === 'ArrowLeft' ? -20 : 0), y: point.y + (event.key === 'ArrowDown' ? 20 : event.key === 'ArrowUp' ? -20 : 0) } })) - }}> -
{node.name}详情
-
{node.online ? '在线' : '离线'}
-
拨出 {outgoing}接入 {incoming}{node.listens ? '监听已开启' : '监听已关闭'}
-
- ) - })} -
-
RTT < 100 ms100–250 ms 或丢包 ≥ 1%> 250 ms 或丢包 ≥ 5%连接中已关闭
-
-
- -
- ) -} diff --git a/web/admin/src/pages/LinkExplorer.tsx b/web/admin/src/pages/LinkExplorer.tsx new file mode 100644 index 0000000..ece9e8e --- /dev/null +++ b/web/admin/src/pages/LinkExplorer.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import type { LinkDto, LinkMatrixDto } from '@shared/api/types' +import { Chip, Empty } from '@shared/ui' +import { lineIdOf, type LineQuality } from './lineQuality' + +type Tone = 'good' | 'fair' | 'poor' | 'connected' | 'pending' | 'disabled' +type SortKey = 'rtt' | 'loss' | 'state' | 'name' + +const STATE_LABELS: Record = { + waiting_report: '等待上报', control_connecting: '控制连接中', control_down: '失联', + task_connecting: 'QUIC 握手中', task_retrying: 'QUIC 重试中', dialer_unavailable: '拨号方不可用', listener_unavailable: '监听方不可用', +} + +/** 严重程度:左边的列表排序、徽标和「只看有问题的」用。已关闭是你自己的选择,不算问题。 */ +const SEVERITY: Record = { poor: 3, pending: 2, fair: 2, disabled: 0, connected: 0, good: 0 } + +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 >= 5) return 'poor' + if (rtt >= 100 || loss >= 1) return 'fair' + return 'good' +} + +const fmtRtt = (ms: number) => `${ms < 10 ? ms.toFixed(1) : Math.round(ms)} ms` + +/** + * 线路管理:左边是 Agent 列表(只显示健康摘要,有问题的排在前面),右边是选中的 Agent 和所有对端的线路。 + * 一对节点只有一条线路,所以右边每一行就是一条线路:谁拨谁、状态、RTT、丢包,行内直接开关。节点再多也不会铺出 N×N 的格子。 + */ +export function LinkExplorer({ data, quality, busy, onSet, onRetry }: { + data: LinkMatrixDto + quality: Record + busy: boolean + onSet: (link: LinkDto, enabled: boolean | null) => Promise + onRetry: (link: LinkDto) => Promise +}) { + const [selectedId, setSelectedId] = useState(null) + const [query, setQuery] = useState('') + const [problemsOnly, setProblemsOnly] = useState(false) + const [sort, setSort] = useState('state') + + // 每个 Agent 的健康摘要 + const summaries = useMemo(() => { + return data.nodes.map((node) => { + const links = data.items.filter((link) => link.dialer_id === node.id || link.listener_id === node.id) + const tones = links.map((link) => toneOf(link, quality[lineIdOf(link)])) + const worst = tones.reduce((max, tone) => Math.max(max, SEVERITY[tone]), 0) + const problems = tones.filter((tone) => SEVERITY[tone] > 0).length + const worstRtt = links.reduce((max, link) => Math.max(max, quality[lineIdOf(link)]?.rtt ?? 0), 0) + return { node, links, worst, problems, worstRtt, connected: links.filter((link) => link.connected).length } + }) + }, [data.nodes, data.items, quality]) + + const list = useMemo(() => { + const needle = query.trim().toLowerCase() + return summaries + .filter((s) => (needle ? s.node.name.toLowerCase().includes(needle) : true)) + .filter((s) => !problemsOnly || s.problems > 0) + .sort((a, b) => b.worst - a.worst || b.problems - a.problems || a.node.name.localeCompare(b.node.name, undefined, { numeric: true })) + }, [summaries, query, problemsOnly]) + + // 没选或选的不在列表里:选最需要关注的那个 + useEffect(() => { + if ((selectedId === null || !data.nodes.some((n) => n.id === selectedId)) && summaries.length > 0) { + const first = [...summaries].sort((a, b) => b.worst - a.worst || b.problems - a.problems || a.node.name.localeCompare(b.node.name, undefined, { numeric: true }))[0] + setSelectedId(first.node.id) + } + }, [selectedId, data.nodes, summaries]) + + const current = summaries.find((s) => s.node.id === selectedId) + const rows = useMemo(() => { + if (!current) return [] + const keyed = current.links.map((link) => { + const outgoing = link.dialer_id === current.node.id + const q = quality[lineIdOf(link)] + return { link, outgoing, peerId: outgoing ? link.listener_id : link.dialer_id, peerName: outgoing ? link.listener_name : link.dialer_name, q, tone: toneOf(link, q) } + }) + return keyed.sort((a, b) => { + switch (sort) { + case 'rtt': return (b.q?.rtt ?? -1) - (a.q?.rtt ?? -1) + case 'loss': return (b.q?.loss ?? -1) - (a.q?.loss ?? -1) + case 'name': return a.peerName.localeCompare(b.peerName, undefined, { numeric: true }) + default: return SEVERITY[b.tone] - SEVERITY[a.tone] || (b.q?.rtt ?? -1) - (a.q?.rtt ?? -1) + } + }) + }, [current, quality, sort]) + + async function each(links: LinkDto[], enabled: boolean | null) { + for (const link of links) await onSet(link, enabled) + } + + if (data.nodes.length === 0) return 还没有已启用的 Agent,请先添加并启用节点。 + + return ( +
+ + +
+ {current ? ( + <> +
+
+ {current.node.name} + + {current.node.online ? '在线' : '离线'} + {current.node.listens ? '监听' : '只拨出'} · {current.links.length} 条线路,{current.connected} 条已连接{current.problems > 0 ? `,${current.problems} 条需要关注` : ''} + +
+ + + + + + +
+ {rows.length === 0 ? 这个 Agent 还没有线路:添加并启用其他 Agent 后会自动连线。 : ( + + + + + + {rows.map(({ link, outgoing, peerId, peerName, q, tone }) => { + const peerNode = data.nodes.find((n) => n.id === peerId) + const canSwap = data.nodes.find((n) => n.id === link.dialer_id)?.listens === true + return ( + + + + + + + + + ) + })} + +
对端方向状态RTT(近 3 小时)丢包
+ {outgoing ? '本机拨出 →' : '← 对端拨入'} + {!link.linked ? '已关闭' : link.connected ? '已连接' : STATE_LABELS[link.connection_state] ?? '连接中'} + {link.override != null ? 手动 : null} + {link.linked && !link.connected && link.connection_reason ?
{link.connection_reason}
: null} +
{q?.rtt != null ? fmtRtt(q.rtt) : '—'}= 1 ? ' warn-text' : ''}`}>{q?.loss != null ? `${q.loss.toFixed(2)}%` : '—'} + + {link.linked && !link.connected ? : null} + {link.override != null ? : null} + {canSwap && peerNode?.listens !== false ? ( + + ) : null} + + +
+ )} + + ) : null} +
+
+ ) +} diff --git a/web/admin/src/pages/Links.tsx b/web/admin/src/pages/Links.tsx index 0e2b868..cabe2ed 100644 --- a/web/admin/src/pages/Links.tsx +++ b/web/admin/src/pages/Links.tsx @@ -1,33 +1,18 @@ -import { LinkBoard, type LineQuality } from './LinkBoard' +import { LinkExplorer } from './LinkExplorer' import { api } from '@shared/api/client' -import type { LinkDto, LinkMatrixDto, OverviewDto } from '@shared/api/types' +import type { LinkDto, LinkMatrixDto } from '@shared/api/types' import { useQuery } from '@shared/hooks' -import { runtimeStatusLabel } from '@shared/nodes' -import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui' +import { ErrorNotice, Spinner, useAction } from '@shared/ui' +import { useLineQuality } from './lineQuality' /** * 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的; - * 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;国内的节点默认只拨出;其余 id 较小的拨),「恢复默认连接模式」清掉所有手动设置。 + * 每一对都可以手动强制开或关。拨号的一方由面板决定(不监听的拨监听的;国内的节点默认只拨出;其余 id 较小的拨),「恢复默认连接模式」清掉所有手动设置。 */ -/** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */ -const CONNECTION_LABELS: Record = { - waiting_report: '等待状态', - control_connecting: '控制连接中', - control_down: '失联', - task_connecting: 'QUIC 握手中', - task_retrying: 'QUIC 重试中', -} - -export function Links({ dialerId }: { dialerId?: string }) { +export function Links() { 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 quality = useLineQuality() 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) - const agents = (matrix.data?.nodes ?? []).map((n) => [n.id, n.name] as const) - const cell = (dialerId: string, agentId: string) => items.find((i) => i.dialer_id === dialerId && i.listener_id === agentId) const set = async (link: LinkDto, enabled: boolean | null) => { await run(() => api.put(`/api/admin/links/${link.dialer_id}/${link.listener_id}`, { enabled })) matrix.reload() @@ -48,72 +33,17 @@ export function Links({ dialerId }: { dialerId?: string }) { return (
-
- {dialerId ? '拨出连接' : 'Agent 节点画板'} +
+ Agent 线路 {matrix.loading && !matrix.data ? : null} - {dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'} - {!dialerId ? : null} - {!dialerId ?
- {!dialerId && matrix.data ? : null} - {dialerId && matrix.data && items.length === 0 ? 尚无可连接的线路,请添加并启用其他 Agent。 : null} - {dialerId ? - - - - {agents.map(([id, name]) => ( - - ))} - - - - {dialers.map(([dialerId, dialerName]) => ( - - - {agents.map(([agentId]) => { - const link = cell(dialerId, agentId) - if (!link) return - const tone = link.connected ? 'ok' : link.linked ? 'warn' : '' - const text = link.connected ? '已连接' : !link.linked ? '未连接' : CONNECTION_LABELS[link.connection_state] ?? (link.dialer_runtime_status !== 'running' ? runtimeStatusLabel(link.dialer_runtime_status) : link.listener_runtime_status !== 'running' ? runtimeStatusLabel(link.listener_runtime_status) : '连接中') - const statusHint = link.connected - ? '两个 Agent 已 建立测量会话' - : !link.linked - ? '这条线路已关闭' - : [link.connection_reason, link.last_error, link.retry_in_ms ? `约 ${Math.ceil(link.retry_in_ms / 1000)} 秒后自动重试` : null].filter(Boolean).join(';') || `来源 Agent:${runtimeStatusLabel(link.dialer_runtime_status)};目标 Agent:${runtimeStatusLabel(link.listener_runtime_status)}` - return ( - - ) - })} - - ))} - -
来源 \ 目标{name}
{dialerName}{dialerId === agentId ? '—' : '未监听'} - - - {link.override !== null && link.override !== undefined ? ( - <> - - 手动 - - - - ) : null} - {link.linked && !link.connected ? ( - - ) : null} - - {!link.connected && link.linked && link.connection_reason ?
{link.connection_reason}
: null} -
: null} + {matrix.data ? : null}
) } diff --git a/web/admin/src/pages/lineQuality.ts b/web/admin/src/pages/lineQuality.ts new file mode 100644 index 0000000..3ea9800 --- /dev/null +++ b/web/admin/src/pages/lineQuality.ts @@ -0,0 +1,16 @@ +import { api } from '@shared/api/client' +import type { OverviewDto } from '@shared/api/types' +import { useQuery } from '@shared/hooks' +import type { LinkDto } from '@shared/api/types' + +/** 线路质量:摘要里的 RTT 中位数(毫秒)和总丢包率(百分比,0.5 就是 0.5%)。 */ +export type LineQuality = { rtt: number | null; loss: number | null } + +/** 线路 id 是 <监听方>~<拨号方>。 */ +export const lineIdOf = (link: LinkDto) => `${link.listener_id}~${link.dialer_id}` + +/** 每条线路近 3 小时的 RTT 中位数和丢包(百分比),键是线路 id(<监听方>~<拨号方>);没有数据的线路不在里面。 */ +export function useLineQuality(): Record { + const overview = useQuery(() => api.get('/api/public/overview', { range: '3h', metrics: 'rtt.p50,loss.total', ip_version: 'ipv4' }), [], 30_000) + return Object.fromEntries((overview.data?.lines ?? []).map((line) => [line.id, { rtt: line.summary.metrics['rtt.p50'] ?? null, loss: line.summary.metrics['loss.total'] ?? null }])) +}