import { ChevronLeft } from 'lucide-react' import { useMemo } from 'react' import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions' import { Link, useParams, useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { LineDto, TestDto } from '@shared/api/types' import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart' import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format' import { NodeTag, useNodeInfos } from '@shared/nodes' import { fmtBytes, useHosts } from '../live' import { useQuery } from '@shared/hooks' import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui' import { PageError, ScoreChips } from '../lib' import { Minimap } from '../Minimap' import { readSpec, usePeriod, usePeriodSeries, writeSpec } from '../periods' import { dayBounds, dayKey, RangePicker, WINDOWS, type WindowValue } from '../ranges' const MIN = 60_000 function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' { if (value === null || value === undefined) return '' if (value <= good) return 'ok' if (value >= poor) return 'bad' return 'warn' } interface HostSeries { t: number[] values: Record } /** 指标卡片:好坏的分界(越低越好)和显示格式。 */ const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [ { label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs }, { label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs }, { label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 }, { label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 }, { label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 }, { label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' }, { label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs }, { label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs }, { label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs }, { label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs }, { label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs }, { label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 }, { label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true }, { label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true }, { label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true }, ] interface ChartDef { id: string title: string unit: string zero?: boolean fill?: boolean series: { key: string; label: string; color: string; dash?: number[] }[] group: 'udp' | 'tcp' | 'connect' | 'score' /** 占满一行。 */ wide?: boolean } const GROUPS = [ { id: 'udp', title: 'UDP(往返探测)' }, { id: 'tcp', title: 'TCP' }, { id: 'connect', title: '建连(TCP 握手、TLS 握手)' }, { id: 'score', title: '评分' }, ] as const const CHARTS: ChartDef[] = [ { id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] }, { id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] }, { id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] }, { id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] }, { id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] }, { id: 'connect-time', title: '建连耗时(p95)', group: 'connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] }, { id: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] }, { id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] }, ] /** EWMA 平滑的力度:系数越小越平滑(s = α·x + (1−α)·s)。 */ const EWMA = [ { value: 'off', label: '关', alpha: 0 }, { value: 'low', label: '轻', alpha: 0.3 }, { value: 'mid', label: '中', alpha: 0.1 }, { value: 'high', label: '重', alpha: 0.03 }, ] as const type EwmaValue = (typeof EWMA)[number]['value'] /** 指数加权移动平均;缺口(没有数据)处断开,缺口后重新开始,不会把缺口两边连在一起。 */ function ewma(values: (number | null)[], alpha: number): (number | null)[] { let state: number | null = null return values.map((v) => { if (v === null || v === undefined) { state = null return null } state = state === null ? v : alpha * v + (1 - alpha) * state return state }) } /** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */ function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) { const history = useQuery( () => (agentId && range ? api.get(`/api/public/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)), [agentId, range], 30_000, ) const { hosts } = useHosts() const host = agentId ? hosts.get(agentId)?.host : undefined const data = history.data const charts = useMemo( () => data ? { usage: [ { label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' }, { label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' }, { label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' }, ], speed: [ { label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' }, { label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' }, ], } : undefined, [data], ) if (!host && !data) return null return (
主机状态
{host ? (
) : null} {data && charts ? (
) : null}
) } export function Line() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() const line = useQuery(() => api.get(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000) const infos = useNodeInfos() const ready = Boolean(line.data) const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null const nowMs = useMemo(() => Date.now(), [line.data]) const todayStart = dayBounds(nowMs).start const spec = readSpec(params) const period = usePeriod(id, ready, sinceMs, nowMs, spec) const series = usePeriodSeries(id, ready, period) const update = (fn: (q: URLSearchParams) => void) => { const q = new URLSearchParams(params) fn(q) setParams(q, { replace: true }) } const dayParam = (dayStart: number) => (dayStart === todayStart ? null : dayKey(dayStart)) const changeDay = (next: { dayStart: number; window: WindowValue | null }) => update((q) => writeSpec(q, { ...readSpec(q), day: dayParam(next.dayStart), range: next.window, at: null })) const selectRange = (fromMs: number, toMs: number) => update((q) => { const a = Math.round((fromMs - period.dayStart) / MIN) const b = Math.round((toMs - period.dayStart) / MIN) writeSpec(q, { ...readSpec(q), day: dayParam(period.dayStart), range: null, at: `${a}_${b}` }) }) /** 点了一档时长。看的是最新的(今天、选区到现在)就继续看「最近一段」;否则保持选区的起点,往后取这么长。 */ const pickLength = (w: WindowValue) => update((q) => { const base = { ...readSpec(q), day: dayParam(period.dayStart) } const info = WINDOWS.find((x) => x.value === w)! if (w === 'day' || (period.isToday && (period.live || period.toMs >= nowMs - 2 * MIN))) { writeSpec(q, { ...base, range: w, at: null }) return } const start = Math.min(period.fromMs, nowMs - info.ms) const a = Math.round((start - period.dayStart) / MIN) writeSpec(q, { ...base, range: null, at: `${a}_${a + info.ms / MIN}` }) }) const setScale = (scale: 24 | 48) => update((q) => { const current = readSpec(q) // 换回 24 小时:跨零点的选区放不下了,回到默认 writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at }) }) const agentId = line.data?.agent_id const lastTestId = line.data?.last_test?.id const test = useQuery(() => (lastTestId ? api.get(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId]) const smooth = EWMA.find((x) => x.value === params.get('ewma')) ?? EWMA[0] const s = series.data const m = s?.summary.metrics ?? {} const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s]) // 图表只在数据变化时更新;别的状态(加载中、实时推送)变化不会让它们闪 const charts = useMemo( () => s ? GROUPS.map((group) => { const cards = CHARTS.filter((def) => def.group === group.id).map((def) => { const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] })) // 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面 const list: ChartSeries[] = smooth.alpha > 0 ? [ ...raw.map(({ x, values }) => ({ label: `${x.label}(原始)`, values, color: x.color, faint: true })), ...raw.map(({ x, values }) => ({ label: x.label, values: ewma(values, smooth.alpha), color: x.color, dash: x.dash, fill: def.fill })), ] : raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill })) if (!list.some((x) => x.values.some((v) => v !== null))) return null return { def, node: } }) return { group, cards: cards.filter((c): c is NonNullable => c !== null) } }).filter((g) => g.cards.length > 0) : [], [s, gaps, smooth.alpha], ) const loadingNow = !ready || series.busy const chartSections = charts.map(({ group, cards }) => (

{group.title}

{cards.map(({ def, node }) => (

{def.title}

{node}
))}
)) const result = test.data?.result const peak = result?.metrics?.['tcp.bulk.peak.max'] return (
总览

{line.data?.name ?? '线路'} {line.data ? → {line.data.probe_name} : null}

{IP_VERSIONS.map((v) => { const query = new URLSearchParams(params) query.set('ip_version', v.value) return ( {v.label} ) })} {line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? 此 Agent 未配置 IPv6 地址 : null}
{line.data ? (
{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,测量通道未建立(Probe 没能和它建立测量会话;机器可能是通的,ping 通不代表测量用的 TCP 和 UDP 通道可用)') : 'Agent 离线'} Agent Probe {line.data.labels.map((x) => ( {x} ))} {series.loading && !s ? : null}
) : null}
1440 ? 48 : 24} onScale={setScale} sinceMs={sinceMs} nowMs={nowMs} spanMs={period.spanMs} >
{!s && (loadingNow || !line.error) && !series.error ? ( // 还没有数据:先摆出占位,每块上面有进度条
{Array.from({ length: 12 }, (_, i) => (
))}
{[0, 1].map((i) => (
))}
) : null} {s ? (
评分
{TILES.filter((t) => !t.optional || m[t.key] != null).map((t) => ( ))}

样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}

{s.summary.coverage < 0.95 ?

这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。

: null}
EWMA 平滑 options={EWMA.map((x) => ({ value: x.value, label: x.label }))} value={smooth.value} onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))} />
{chartSections} {test.data && result ? (
最近一次测试 · {test.data.plan}
{peak !== undefined ? : null} {result.metrics['connect.success'] !== undefined ? : null} {result.metrics['connect.tls_ms.p95'] !== undefined ? : null} {result.metrics['hy2.sustainable_mbps'] !== undefined ? : null} {result.metrics['hy2.qos_onset_mbps'] !== undefined ? : null}

{fmtTime(test.data.finished_at)}({fmtAgo(test.data.finished_at)})

) : null} {s.events.length > 0 ? (
事件
{s.events.slice(0, 20).map((e) => (
{fmtTime(e.at)} {e.kind} {e.detail ? {e.detail} : null}
))}
) : null}
) : null}
) }