import { ChevronLeft } from 'lucide-react' import { useMemo, useState } 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 { 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' } /** 指标卡片:好坏的分界(越低越好)和显示格式。 */ type MetricGroup = 'quic' | 'tcp-connect' | 'score' const METRIC_GROUPS: { value: MetricGroup; label: string }[] = [ { value: 'quic', label: 'QUIC' }, { value: 'tcp-connect', label: 'TCP' }, { value: 'score', label: '评分' }, ] const QUIC_METRICS = 'rtt.p50,rtt.p95,rtt.p99,loss.total,loss.up,loss.down,loss.run.up_max,loss.run.down_max,jitter.up.p95,jitter.down.p95' const TILES: { group: Exclude; label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [ { group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs }, { group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs }, { group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs }, { group: 'tcp-connect', label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 }, { group: 'tcp-connect', label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true }, { group: 'tcp-connect', label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true }, { group: 'tcp-connect', 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: MetricGroup /** 占满一行。 */ wide?: boolean } const GROUPS = [ { id: 'quic', title: 'QUIC Datagram' }, { id: 'tcp-connect', title: 'TCP' }, { id: 'score', title: '评分' }, ] as const const CHARTS: ChartDef[] = [ { id: 'rtt', title: '延迟', group: 'quic', 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: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] }, { id: 'loss', title: '丢包', group: 'quic', 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: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '上行', color: '--chart-2' }, { key: 'loss.run.down_max', label: '下行', color: '--chart-3' }] }, { id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', 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-connect', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] }, { id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-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: 'tcp-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 }) } export function Line() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() const [metricGroup, setMetricGroup] = useState('quic') 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, metricGroup === 'score' ? 'scores' : metricGroup === 'tcp-connect' ? 'tcp,connect' : metricGroup, metricGroup === 'quic' ? QUIC_METRICS : undefined, ) 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 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.filter((group) => group.id === metricGroup).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, metricGroup], ) const loadingNow = !ready || series.busy const chartSections = charts.map(({ group, cards }) => (
{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 {line.data.labels.map((x) => ( {x} ))} {series.loading && !s ? : null}
) : null}
1440 ? 48 : 24} onScale={setScale} sinceMs={sinceMs} nowMs={nowMs} spanMs={period.spanMs} toolbar={
平滑 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)))} />
} >
{!s && (loadingNow || !line.error) && !series.error ? ( // 还没有数据:先摆出占位,每块上面有进度条
{Array.from({ length: 12 }, (_, i) => (
))}
{[0, 1].map((i) => (
))}
) : null} {s ? (

{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}

{metricGroup === 'quic' ? 往返时延 · 双向丢包 · 抖动 : null}
options={METRIC_GROUPS} value={metricGroup} onChange={setMetricGroup} />
{metricGroup === 'quic' ? <>

QUIC RTT

p50{fmtMs(m['rtt.p50'])}
p95 {fmtMs(m['rtt.p95'])}|p99 {fmtMs(m['rtt.p99'])}

QUIC Datagram 丢包

总丢包{fmtPct(m['loss.total'])}
↑ {fmtPct(m['loss.up'])}/↓ {fmtPct(m['loss.down'])}
最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包

QUIC 抖动 p95

↑ {fmtMs(m['jitter.up.p95'])}↓ {fmtMs(m['jitter.down.p95'])}
QUIC 会话与传输 {line.data?.quic_health ? (() => { const h = line.data.quic_health! return <>
握手 p95 {fmtMs(h.handshake_p95_ms)} 成功率 {fmtPct(h.handshake_success_rate)} 超时 {fmtPct(h.handshake_timeout_rate)} 失败 {fmtPct(h.handshake_failure_rate)} 传输疑似丢包 {fmtPct(h.transport_packet_loss_rate)} 路径 MTU {fmtNum(h.path_mtu)} B
})() : Probe 尚未上报会话数据}
: null} {metricGroup !== 'quic' ?
{metricGroup === 'score' ?
评分
: null} {TILES.filter((t) => t.group === metricGroup && (!t.optional || m[t.key] != null)).map((t) => ( ))}
: null}
样本数:QUIC Datagram {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}
{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}
) : null}
) }