web: unify Agent management and add a draggable link board

Replace Probe pages with directed Agent links, shared deployment controls and unified display data. Add node dragging, canvas pan and zoom, saved layouts, and per-direction link controls.
This commit is contained in:
justme committed 2026-10-21 11:55:50 +08:00
1 parent 016dbc2bbf
commit 15f0023614
32 files changed
+937 -1093

No files matched your search

+28 -28
View File
@@ -16,7 +16,7 @@ const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss
type SortKey = 'name' | 'rtt' | 'loss'
/** 线路按 Probe 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
/** 线路按 来源 Agent 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
const CARRIERS = [
{ key: 'telecom', name: '电信' },
{ key: 'unicom', name: '联通' },
@@ -31,7 +31,6 @@ const average = (values: Array<number | null | undefined>) => {
return v.length === 0 ? null : v.reduce((a, b) => a + b, 0) / v.length
}
const KIND_NAME = { agent: 'Agent', probe: 'Probe' } as const
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
@@ -47,7 +46,7 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
)
}
/** 一个节点(Agent 或 Probe)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
/** 一个节点(Agent)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; infos: Map<string, NodeInfoDto> }) {
const h = node.host
const stale = !node.online || node.stale
@@ -58,7 +57,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
<span className="agent-name">{node.name}</span>
<span className="faint kind-tag">{KIND_NAME[node.kind]}</span>
<span className="faint kind-tag">Agent</span>
<Chip tone={node.online ? 'ok' : 'bad'}>{node.online ? '在线' : '离线'}</Chip>
{!node.online && node.last_seen_at ? <span className="faint">{fmtAgo(node.last_seen_at)}</span> : null}
</div>
@@ -130,41 +129,43 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
</div>
</>
) : (
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
<div className="faint">这台 Agent 没有上报主机状态</div>
)}
</div>
</Link>
)
}
/** 一条线路(Probe → Agent):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
/** 一条线路(拨号方 → 监听方):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const probeInfo = infos.get(l.probe_id)
const dialerInfo = infos.get(l.dialer_id)
const agentInfo = infos.get(l.agent_id)
const rangeLabel = OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range
const connectionIssue = !l.online
? 'Agent 离线'
: l.quic_health?.state === 'retry_wait'
? 'QUIC 拨号失败'
: !l.probe_connected
? '测量通道未建立'
: l.quic_health && !l.quic_health.connected
? 'QUIC 正在连接'
: null
: l.quic_health?.control_state === 'backoff'
? '与 Agent 失联'
: l.quic_health?.state === 'retry_wait'
? 'QUIC 拨号失败'
: !l.dialer_connected
? '测量通道未建立'
: l.quic_health && !l.quic_health.connected
? 'QUIC 正在连接'
: null
return (
<Link to={`/line/${encodeURIComponent(l.id)}`} className="line-link line-card">
<div className="line-top">
<span className="line-name" title={[l.probe_name, probeInfo?.tag || probeInfo?.version].filter(Boolean).join(' · ')}>
{probeInfo?.country ? <Flag code={probeInfo.country} /> : null}
<span className="name">{l.probe_name}</span>
{probeInfo?.isp_key ? <IspIcon ispKey={probeInfo.isp_key} name={probeInfo.isp_name} /> : null}
<span className="line-name" title={[l.dialer_name, dialerInfo?.tag || dialerInfo?.version].filter(Boolean).join(' · ')}>
{dialerInfo?.country ? <Flag code={dialerInfo.country} /> : null}
<span className="name">{l.dialer_name}</span>
{dialerInfo?.isp_key ? <IspIcon ispKey={dialerInfo.isp_key} name={dialerInfo.isp_name} /> : null}
<span className="arrow">→</span>
{agentInfo?.country ? <Flag code={agentInfo.country} /> : null}
<span className="name">{l.name}</span>
</span>
<span
className={connectionIssue ? 'line-range connection-issue' : 'faint line-range'}
title={l.quic_health?.state === 'retry_wait' ? `Probe 正在等待退避重试${l.quic_health.retry_in_ms ? `(约 ${Math.ceil(l.quic_health.retry_in_ms / 1000)} 秒)` : ''}。` : !l.probe_connected && l.online ? 'Agent 在线,但 Probe 尚未建立测量会话。' : undefined}
title={l.quic_health?.state === 'retry_wait' ? `来源 Agent 正在等待退避重试${l.quic_health.retry_in_ms ? `(约 ${Math.ceil(l.quic_health.retry_in_ms / 1000)} 秒)` : ''}。` : !l.dialer_connected && l.online ? 'Agent 在线,但 来源 Agent 尚未建立测量会话。' : undefined}
>
{connectionIssue ? `${connectionIssue} · ${rangeLabel}` : rangeLabel}
</span>
@@ -196,13 +197,12 @@ export function Overview() {
)
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 10_000)
// 节点卡片:先 Agent 后 Probe,各自在线的在前、再按名字;Agent 的标签来自它的线路
// 节点卡片:在线节点在前、再按名字;Agent 的标签来自它的线路
const nodes = useMemo(() => {
const labels = new Map<string, string[]>()
for (const l of data?.lines ?? []) labels.set(l.agent_id, l.labels)
const rank = (n: HostItem) => (n.kind === 'agent' ? 0 : 1)
return [...hosts.values()]
.sort((a, b) => rank(a) - rank(b) || Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
.map((node) => ({ node, labels: labels.get(node.id) ?? [] }))
}, [hosts, data])
// 线路面板:按 IP 版本过滤,再排序
@@ -211,7 +211,7 @@ export function Overview() {
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
return picked.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1
const byName = () => a.name.localeCompare(b.name) || a.probe_name.localeCompare(b.probe_name)
const byName = () => a.name.localeCompare(b.name) || a.dialer_name.localeCompare(b.dialer_name)
if (sort === 'name') return byName()
const x = a.summary.metrics[key]
const y = b.summary.metrics[key]
@@ -221,15 +221,15 @@ export function Overview() {
return x - y
})
}, [data, sort, ipVersion])
// 按 Probe 的运营商分组(固定顺序,没有线路的组不显示)
// 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示)
const groups = useMemo(
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.probe_id)) === c.key) })).filter((g) => g.lines.length > 0),
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.dialer_id)) === c.key) })).filter((g) => g.lines.length > 0),
[lines, infos],
)
const shown = groups.filter((g) => !carrier || g.key === carrier)
const count = (kind: 'agent' | 'probe') => {
const all = nodes.filter((n) => n.node.kind === kind)
const count = () => {
const all = nodes
return `${all.filter((n) => n.node.online).length} / ${all.length}`
}
@@ -238,7 +238,7 @@ export function Overview() {
<section className="hero">
<h1>总览</h1>
<p className="sub">
{hosts.size > 0 || data ? `Agent ${count('agent')} 在线 · Probe ${count('probe')} 在线` : '加载中…'}
{hosts.size > 0 || data ? `Agent ${count()} 在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</p>
</section>