web: manage links in a master-detail explorer instead of the board

This commit is contained in:
justme committed 2026-10-22 05:59:56 +08:00
1 parent 70fc033789
commit f62eefd365
5 files changed
+456 -645

No files matched your search

+234 -287
View File
@@ -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;
}
-275
View File
@@ -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<string, Point>
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<string, string> = {
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<string, LineQuality>
busy: boolean
onSet: (link: LinkDto, enabled: boolean | null) => Promise<void>
onRetry: (link: LinkDto) => Promise<void>
}) {
const storageKey = `moshiro.link-board:${getBase() || window.location.origin}`
const [positions, setPositions] = useState<Positions>(() => readPositions(storageKey))
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 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 (
<div className="link-board-layout">
<section className="link-board" aria-label="Agent 节点画板">
<div className="link-board-tools">
<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>
<button type="button" className="btn small" aria-label="放大画板" onClick={() => zoom(1.2)}><Plus size={16} /></button>
<button type="button" className="btn small" title="显示全部节点" aria-label="显示全部节点" onClick={() => fit()}><Maximize size={16} /></button>
<button type="button" className="btn small" onClick={() => { setPositions({}); fit({}) }}><RotateCcw size={14} />重置布局</button>
</div>
</div>
<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="节点之间的线路">
{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 (
<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} />
<g className="link-board-edge-label" transform={`translate(${labelX}, ${labelY})`}>
<rect x="-40" y="-12" width="80" height="24" rx="12" />
<text textAnchor="middle" dominantBaseline="central">{text}</text>
</g>
</g>
)
})}
</svg>
{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 (
<div key={node.id} className={`link-board-node${node.online ? '' : ' offline'}`} style={{ left: point.x, top: point.y }} tabIndex={0} role="group" aria-label={`${node.name} 节点,方向键移动`} onPointerDown={(event) => 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) } }))
}}>
<div className="link-board-node-head"><Server size={18} /><strong title={node.name}>{node.name}</strong><Link to={`/agents/${encodeURIComponent(node.id)}`} className="link-board-detail">详情</Link></div>
<div className="row"><RuntimeStatusChip status={node.runtime_status} /><span className="faint">{node.online ? '在线' : '离线'}</span></div>
<div className="link-board-node-counts"><span>拨出 <b>{outgoing}</b></span><span>接入 <b>{incoming}</b></span><span>{node.listens ? '监听已开启' : '监听已关闭'}</span></div>
</div>
)
})}
</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>)}
</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="row wrap"><Chip tone={focused.connected ? 'ok' : focused.linked ? 'warn' : ''}>{labelOf(focused)}</Chip><span className="muted">{focused.override == null ? '跟随自动' : '手动设置'}</span></div>
<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" disabled={busy || focused.override == null} onClick={() => onSet(focused, null)}>恢复自动</button>
<button type="button" className="btn" disabled={busy} onClick={() => onRetry(focused)}>立即重试</button>
<span className="faint">一对节点只有一条线路,上下行都在这条连接上测,RTT 只测一次。</span>
</div>
) : <Empty>点击画板中的连线,查看线路质量或开关这条线路。</Empty>}
<p className="link-board-storage faint">节点位置仅保存在当前浏览器。</p>
</aside>
</div>
)
}
+193
View File
@@ -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<string, string> = {
waiting_report: '等待上报', control_connecting: '控制连接中', control_down: '失联',
task_connecting: 'QUIC 握手中', task_retrying: 'QUIC 重试中', dialer_unavailable: '拨号方不可用', listener_unavailable: '监听方不可用',
}
/** 严重程度:左边的列表排序、徽标和「只看有问题的」用。已关闭是你自己的选择,不算问题。 */
const SEVERITY: Record<Tone, number> = { 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<string, LineQuality>
busy: boolean
onSet: (link: LinkDto, enabled: boolean | null) => Promise<void>
onRetry: (link: LinkDto) => Promise<void>
}) {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [problemsOnly, setProblemsOnly] = useState(false)
const [sort, setSort] = useState<SortKey>('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 <Empty>还没有已启用的 Agent,请先添加并启用节点。</Empty>
return (
<div className="link-explorer">
<aside className="link-explorer-list" aria-label="Agent 列表">
<div className="link-explorer-filter">
<input className="input" type="search" placeholder={`搜索 ${data.nodes.length} 个 Agent`} value={query} onChange={(event) => setQuery(event.target.value)} />
<label className="switch">
<input type="checkbox" checked={problemsOnly} onChange={(event) => setProblemsOnly(event.target.checked)} /> 只看有问题的
<span className="link-explorer-hint" tabIndex={0} title={'圆点:它最差的一条线路\n红:RTT > 250 ms 或丢包 ≥ 5%\n黄:RTT 100–250 ms、丢包 ≥ 1% 或连接中\n数字:需要关注的线路数;没有问题时是 已连接/总数'}>ⓘ</span>
</label>
</div>
<ul>
{list.map((s) => (
<li key={s.node.id}>
<button type="button" className={`link-explorer-agent${s.node.id === selectedId ? ' selected' : ''}${s.node.online ? '' : ' offline'}`} onClick={() => setSelectedId(s.node.id)}>
<span className={`link-dot ${!s.node.online ? 'offline' : s.worst >= 3 ? 'poor' : s.worst >= 2 ? 'fair' : 'good'}`} aria-hidden="true" />
<span className="name">{s.node.name}</span>
{s.node.listens ? null : <small>只拨出</small>}
{s.problems > 0 ? <span className={`badge ${s.worst >= 3 ? 'poor' : 'fair'}`} title={`${s.problems} 条线路需要关注`}>{s.problems}</span> : <span className="count">{s.connected}/{s.links.length}</span>}
</button>
</li>
))}
{list.length === 0 ? <li className="faint empty">没有匹配的 Agent</li> : null}
</ul>
</aside>
<section className="link-explorer-detail" aria-label="选中 Agent 的线路">
{current ? (
<>
<div className="link-explorer-head">
<div className="stack">
<b><Link to={`/agents/${current.node.id}`}>{current.node.name}</Link></b>
<span className="row wrap muted">
<Chip tone={current.node.online ? 'ok' : ''}>{current.node.online ? '在线' : '离线'}</Chip>
{current.node.listens ? '监听' : '只拨出'} · {current.links.length} 条线路,{current.connected} 条已连接{current.problems > 0 ? `,${current.problems} 条需要关注` : ''}
</span>
</div>
<span className="right row wrap">
<label className="row">排序
<select className="input" value={sort} onChange={(event) => setSort(event.target.value as SortKey)}>
<option value="state">问题优先</option>
<option value="rtt">RTT 高到低</option>
<option value="loss">丢包高到低</option>
<option value="name">名称</option>
</select>
</label>
<button type="button" className="btn small" disabled={busy || current.links.every((l) => !l.linked)} onClick={() => each(current.links.filter((l) => l.linked), false)}>全部关闭</button>
<button type="button" className="btn small" disabled={busy || current.links.every((l) => l.linked)} onClick={() => each(current.links.filter((l) => !l.linked), true)}>全部打开</button>
<button type="button" className="btn small" disabled={busy || current.links.every((l) => l.override == null)} onClick={() => each(current.links.filter((l) => l.override != null), null)}>恢复默认</button>
</span>
</div>
{rows.length === 0 ? <Empty>这个 Agent 还没有线路:添加并启用其他 Agent 后会自动连线。</Empty> : (
<table className="table link-explorer-lines">
<thead>
<tr><th>对端</th><th>方向</th><th>状态</th><th className="r">RTT(近 3 小时)</th><th className="r">丢包</th><th /></tr>
</thead>
<tbody>
{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 (
<tr key={`${link.dialer_id}~${link.listener_id}`} className={tone}>
<td>
<span className={`link-dot ${peerNode?.online ? tone === 'poor' ? 'poor' : tone === 'fair' || tone === 'pending' ? 'fair' : 'good' : 'offline'}`} aria-hidden="true" />{' '}
<button type="button" className="link-explorer-peer" onClick={() => setSelectedId(peerId)} title={`看 ${peerName} 的全部线路`}>{peerName}</button>
</td>
<td className="muted">{outgoing ? '本机拨出 →' : '← 对端拨入'}</td>
<td>
<Chip tone={link.connected ? 'ok' : link.linked ? 'warn' : ''}>{!link.linked ? '已关闭' : link.connected ? '已连接' : STATE_LABELS[link.connection_state] ?? '连接中'}</Chip>
{link.override != null ? <span className="faint"> 手动</span> : null}
{link.linked && !link.connected && link.connection_reason ? <div className="faint" title={link.last_error ?? undefined}>{link.connection_reason}</div> : null}
</td>
<td className="r num">{q?.rtt != null ? fmtRtt(q.rtt) : '—'}</td>
<td className={`r num${(q?.loss ?? 0) >= 1 ? ' warn-text' : ''}`}>{q?.loss != null ? `${q.loss.toFixed(2)}%` : '—'}</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
{link.linked && !link.connected ? <button type="button" className="btn small" disabled={busy} onClick={() => onRetry(link)}>重试</button> : null}
{link.override != null ? <button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => onSet(link, null)}>↺</button> : null}
{canSwap && peerNode?.listens !== false ? (
<button type="button" className="btn small text" disabled={busy} title={`换成 ${link.listener_name} 拨 ${link.dialer_name}`} onClick={() => onSet({ ...link, dialer_id: link.listener_id, dialer_name: link.listener_name, listener_id: link.dialer_id, listener_name: link.dialer_name }, true)}>换方向</button>
) : null}
<button type="button" className={`btn small${link.linked ? '' : ' primary'}`} disabled={busy} onClick={() => onSet(link, !link.linked)}>{link.linked ? '关闭' : '开启'}</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</>
) : null}
</section>
</div>
)
}
+13 -83
View File
@@ -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<string, string> = {
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<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 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 (
<div className="card">
<div className={`row${dialerId ? '' : ' link-board-header'}`} style={{ marginBottom: 12 }}>
<b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b>
<div className="row link-board-header" style={{ marginBottom: 12 }}>
<b title="每对 Agent 一条线路,只测一次 RTT">Agent 线路</b>
{matrix.loading && !matrix.data ? <Spinner /> : null}
<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">
<button type="button" className="btn small right" disabled={busy} title="默认规则:不监听的节点拨监听的;国内(CN)的节点默认只拨出、不当被连接方;其余按 id 小的拨。手动设置会被清掉。" onClick={resetLinks}>恢复默认连接模式</button>
<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}
</label>
</div>
<ErrorNotice error={matrix.error} />
{!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>
<tr>
<th>来源 \ 目标</th>
{agents.map(([id, name]) => (
<th key={id}>{name}</th>
))}
</tr>
</thead>
<tbody>
{dialers.map(([dialerId, dialerName]) => (
<tr key={dialerId}>
<td style={{ fontWeight: 600 }}>{dialerName}</td>
{agents.map(([agentId]) => {
const link = cell(dialerId, agentId)
if (!link) return <td key={agentId} className="faint">{dialerId === agentId ? '—' : '未监听'}</td>
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 (
<td key={agentId}>
<span className="row" style={{ gap: 6 }}>
<button type="button" className="btn small" disabled={busy} title={`${statusHint}。点一下${link.linked ? '手动关闭' : '手动开启'}线路`} onClick={() => set(link, !link.linked)}>
<Chip tone={tone}>{text}</Chip>
</button>
{link.override !== null && link.override !== undefined ? (
<>
<span className="faint" title="手动设置">
手动
</span>
<button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => set(link, null)}>
↺
</button>
</>
) : null}
{link.linked && !link.connected ? (
<button type="button" className="btn small" disabled={busy} title={statusHint} onClick={() => retry(link)}>
立即重试
</button>
) : null}
</span>
{!link.connected && link.linked && link.connection_reason ? <div className="faint" style={{ marginTop: 4, maxWidth: 320 }} title={link.last_error ?? undefined}>{link.connection_reason}</div> : null}
</td>
)
})}
</tr>
))}
</tbody>
</table> : null}
{matrix.data ? <LinkExplorer data={matrix.data} quality={quality} busy={busy} onSet={set} onRetry={retry} /> : null}
</div>
)
}
+16
View File
@@ -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<string, LineQuality> {
const overview = useQuery(() => api.get<OverviewDto>('/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 }]))
}