import { Star } from 'lucide-react' import { useMemo } from 'react' import { useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types' import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart' import { fmtMs, fmtPct } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Empty, Segmented, Spinner } from '@shared/ui' import { lineLabel, PageError } from '../lib' import { PRESETS, type PresetValue } from '../ranges' interface MetricInfo { id: string label: string unit: 'ms' | '%' | '' better: 'lower' | 'higher' } const METRICS: MetricInfo[] = [ { id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' }, { id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' }, { id: 'loss.total', label: '丢包', unit: '%', better: 'lower' }, { id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' }, { id: 'loss.down', label: '下行丢包', unit: '%', better: 'lower' }, { id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' }, { id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' }, { id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' }, { id: 'tcp.retrans', label: 'TCP 重传', unit: '%', better: 'lower' }, { id: 'score.reality', label: 'REALITY 评分', unit: '', better: 'higher' }, { id: 'score.hy2', label: 'Hy2 评分', unit: '', better: 'higher' }, { id: 'score.mc', label: 'MC 评分', unit: '', better: 'higher' }, { id: 'score.base', label: '综合评分', unit: '', better: 'higher' }, ] const COLORS = ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6'] const MAX_LINES = 6 const isPreset = (v: string | null): v is PresetValue => PRESETS.some((p) => p.value === v) function fmt(m: MetricInfo, v: number | null | undefined): string { if (v === null || v === undefined) return '—' return m.unit === 'ms' ? fmtMs(v) : m.unit === '%' ? fmtPct(v) : v.toFixed(0) } interface Cell { line: OverviewLineDto value: number | null /** 在这一列(同一个 Probe)里的名次,从 1 开始;没有数据是 null。 */ rank: number | null /** 比这一列最好的差多少(百分比);最好的是 0。 */ gap: number | null } export function Compare() { const [params, setParams] = useSearchParams() const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d' const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0] const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params]) const overview = useQuery(() => api.get('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000) // 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好) const matrix = useMemo(() => { const lines = overview.data?.lines ?? [] const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1])) const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1])) const byProbe = new Map() for (const [probeId] of probes) { const cells: Cell[] = lines .filter((l) => l.probe_id === probeId) .map((line) => ({ line, value: line.summary.metrics[metric.id] ?? null, rank: null, gap: null })) const ranked = cells.filter((c) => c.value !== null).sort((a, b) => (metric.better === 'lower' ? a.value! - b.value! : b.value! - a.value!)) const best = ranked[0]?.value ?? null ranked.forEach((c, i) => { c.rank = i + 1 c.gap = best === null || best === 0 ? (c.value === best ? 0 : null) : (Math.abs(c.value! - best) / Math.abs(best)) * 100 }) byProbe.set(probeId, cells) } return { probes, agents, byProbe } }, [overview.data, metric]) // 默认选每个 Probe 里最好的那条线路(最多 6 条) const defaults = useMemo(() => { const ids: string[] = [] for (const [probeId] of matrix.probes) { const best = matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) if (best) ids.push(best.line.id) } return ids.slice(0, MAX_LINES) }, [matrix]) const selected = picked.length > 0 ? picked : defaults const compare = useQuery( () => (selected.length ? api.get('/api/public/compare', { lines: selected.join(','), metric: metric.id, range }) : Promise.resolve(undefined)), [selected.join(','), metric.id, range], 15_000, ) const update = (patch: Record) => { const next = new URLSearchParams(params) for (const [k, v] of Object.entries(patch)) { if (v) next.set(k, v) else next.delete(k) } setParams(next, { replace: true }) } const toggle = (id: string) => { const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id].slice(-MAX_LINES) update({ lines: next.join(',') }) } const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.probe_name)])) const series: ChartSeries[] = selected.map((id, i) => ({ label: names.get(id) ?? id, values: compare.data?.values[id] ?? [], color: COLORS[i % COLORS.length] })) // 每个 Probe 的最佳线路 const winners = matrix.probes.map(([probeId, probeName]) => ({ probeName, cell: matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) })) return (

线路对比

同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。

({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} /> {metric.better === 'lower' ? '越低越好' : '越高越好'} {overview.loading && !overview.data ? : null}
{overview.data ? ( matrix.agents.length === 0 ? (
还没有线路
) : ( <> {winners.some((w) => w.cell) ? (
{winners.map((w) => w.cell ? (
从 {w.probeName} 看出去最好 {w.cell.line.name} {fmt(metric, w.cell.value)}
) : null, )}
) : null}
{matrix.probes.map(([id, name]) => ( ))} {matrix.agents.map(([agentId, agentName]) => ( {matrix.probes.map(([probeId]) => { const cell = matrix.byProbe.get(probeId)?.find((c) => c.line.agent_id === agentId) if (!cell) return const on = selected.includes(cell.line.id) const tone = cell.rank === 1 ? 'ok' : cell.gap !== null && cell.gap > 50 ? 'bad' : cell.gap !== null && cell.gap > 15 ? 'warn' : '' return ( ) })} ))}
Agent / Probe {name}
{agentName}—

★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。

{metric.label} 的走势
{compare.data ? : {selected.length ? '加载中…' : '点上面的格子选择线路'}}
) ) : null}
) }