import { useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types' import { fmtAgo } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui' import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live' import { Flag, IspIcon, useNodeInfos } from '@shared/nodes' import { OsIcon } from '@shared/osIcon' import { PageError } from '../lib' import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions' import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips' const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans', 'tcp.retrans.up', 'tcp.retrans.down'].join(',') type SortKey = 'name' | 'rtt' | 'loss' /** 线路按 来源 Agent 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */ const CARRIERS = [ { key: 'telecom', name: '电信' }, { key: 'unicom', name: '联通' }, { key: 'mobile', name: '移动' }, { key: 'other', name: '其他' }, ] as const type CarrierKey = (typeof CARRIERS)[number]['key'] const carrierOf = (info: NodeInfoDto | undefined): CarrierKey => (info?.isp_key === 'telecom' || info?.isp_key === 'unicom' || info?.isp_key === 'mobile' ? info.isp_key : 'other') const average = (values: Array) => { const v = values.filter((x): x is number => typeof x === 'number') return v.length === 0 ? null : v.reduce((a, b) => a + b, 0) / v.length } function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) { const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok' return (
{label} {value === null ? '—' : `${value.toFixed(value >= 10 ? 0 : 1)}%`} {detail ? {detail} : null}
) } /** 一个节点(Agent)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; infos: Map }) { const h = node.host const stale = !node.online || node.stale const info = infos.get(node.id) return (
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */} {info?.country ? : } {node.name} Agent {node.online ? '在线' : '离线'} {!node.online && node.last_seen_at ? {fmtAgo(node.last_seen_at)} : null}
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
{info.isp_name ? (
运营商 {info.isp_key ? : null} {info.isp_name}
) : null} {info.os_name ? (
系统 {[info.os_name, info.arch].filter(Boolean).join(' / ')}
) : null} {info.tag || info.version ? (
版本 {info.tag || info.version}
) : null}
) : null} {labels.length > 0 ? (
{labels.map((x) => ( {x} ))}
) : null}
{h ? ( <>
流量 ↑ {fmtBytes(h.net_tx_total)} ↓ {fmtBytes(h.net_rx_total)}
网速 ↑ {fmtSpeed(node.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(node.online ? h.net_rx_bps : null)}
链路速率 {h.net_link_mbps ? `${h.net_link_mbps} Mbit/s` : '—'}
运行时间 {fmtUptime(h.uptime_seconds)}
) : (
这台 Agent 没有上报主机状态
)}
) } /** 一条线路(拨号方 ⇄ 监听方):RTT 是往返;去程是拨号方到监听方(左到右),回程是监听方到拨号方(右到左)。QUIC 看两个方向的丢包,TCP 看两个方向的重传。 */ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map }) { 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?.control_state === 'backoff' ? '与 Agent 失联' : l.quic_health?.state === 'retry_wait' ? 'QUIC 拨号失败' : !l.dialer_connected ? '测量通道未建立' : l.quic_health && !l.quic_health.connected ? 'QUIC 正在连接' : null return (
{dialerInfo?.country ? : null} {l.dialer_name} {dialerInfo?.isp_key ? : null} ⇄ {agentInfo?.country ? : null} {l.name} {connectionIssue ? `${connectionIssue} · ${rangeLabel}` : rangeLabel}
) } export function Overview() { const [carrier, setCarrier] = useState<'' | CarrierKey>('') const [sort, setSort] = useState('name') const { hosts, live } = useHosts() const infos = useNodeInfos() // 汇总和走势使用同一个时间范围,记在链接里(例如 ?range=48h)。 const [params, setParams] = useSearchParams() const ipVersion = ipVersionOf(params.get('ip_version')) const ipv6 = useQuery(() => ipVersion === 'ipv6' ? api.get>('/api/public/ipv6') : Promise.resolve(undefined), [ipVersion], 15_000) const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h' const setRange = (v: OverviewRange) => setParams( (prev) => { const next = new URLSearchParams(prev) if (v === '3h') next.delete('range') else next.set('range', v) return next }, { replace: true }, ) const { data, error, loading } = useQuery(() => api.get('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 10_000) // 节点卡片:在线节点在前、再按名字;Agent 的标签来自它的线路 const nodes = useMemo(() => { const labels = new Map() for (const l of data?.lines ?? []) labels.set(l.agent_id, l.labels) return [...hosts.values()] .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 版本过滤,再排序 const lines = useMemo(() => { const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion) 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.dialer_name.localeCompare(b.dialer_name) if (sort === 'name') return byName() const x = a.summary.metrics[key] const y = b.summary.metrics[key] if ((x === null || x === undefined) && (y === null || y === undefined)) return byName() if (x === null || x === undefined) return 1 if (y === null || y === undefined) return -1 return x - y }) }, [data, sort, ipVersion]) // 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示) const groups = useMemo( () => 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 = () => { const all = nodes return `${all.filter((n) => n.node.online).length} / ${all.length}` } return (

总览

{hosts.size > 0 || data ? `Agent ${count()} 在线` : '加载中…'}

{nodes.length > 0 ? (
{nodes.map(({ node, labels }) => ( ))}
) : null}
线路 {data ? `${lines.length} 条` : ''} {loading && !data ? : null}
{ const next = new URLSearchParams(params); next.set('ip_version', v); setCarrier(''); setParams(next, { replace: true }) }} /> {groups.length > 1 ? options={[{ value: '', label: '全部' }, ...groups.map((g) => ({ value: g.key, label: g.name }))]} value={carrier} onChange={setCarrier} /> : null} options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} value={range} onChange={setRange} />
{data ? ( lines.length === 0 ? (
{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'} {ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) =>

{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}

) : null}
) : ( shown.map((g) => { const rtt = average(g.lines.map((l) => l.summary.metrics['rtt.p50'])) const loss = average(g.lines.map((l) => l.summary.metrics['loss.total'])) return (
{g.key !== 'other' ? : null} {g.name} {g.lines.length} 条 平均延迟 {rtt === null ? '—' : `${rtt.toFixed(1)} ms`} 平均丢包 {loss === null ? '—' : `${loss.toFixed(2)}%`}
{g.lines.map((l) => ( ))}
) }) ) ) : null}
) }