Files
MoShiro/web/display/src/pages/Compare.tsx
T
2026-10-04 21:46:39 +08:00

214 lines
9.9 KiB
TypeScript

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<OverviewDto>('/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<string, Cell[]>()
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<CompareDto>('/api/public/compare', { lines: selected.join(','), metric: metric.id, range }) : Promise.resolve(undefined)),
[selected.join(','), metric.id, range],
15_000,
)
const update = (patch: Record<string, string | null>) => {
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 (
<main className="page">
<section className="hero">
<h1>线路对比</h1>
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
<div className="toolbar">
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
<select className="select" style={{ width: 'auto' }} value={metric.id} onChange={(e) => update({ metric: e.target.value })}>
{METRICS.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<span className="muted">{metric.better === 'lower' ? '越低越好' : '越高越好'}</span>
<span className="right">{overview.loading && !overview.data ? <Spinner /> : null}</span>
</div>
</section>
<PageError error={overview.error ?? compare.error} />
{overview.data ? (
matrix.agents.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<Empty>还没有线路</Empty>
</div>
) : (
<>
{winners.some((w) => w.cell) ? (
<div className="winners">
{winners.map((w) =>
w.cell ? (
<div key={w.probeName} className="card winner">
<span className="faint">从 {w.probeName} 看出去最好</span>
<span className="winner-name">
<Star size={16} /> {w.cell.line.name}
</span>
<span className="num muted">{fmt(metric, w.cell.value)}</span>
</div>
) : null,
)}
</div>
) : null}
<div className="card" style={{ marginTop: 16, padding: '8px 12px' }}>
<table className="table matrix">
<thead>
<tr>
<th>Agent / Probe</th>
{matrix.probes.map(([id, name]) => (
<th key={id} className="r">
{name}
</th>
))}
</tr>
</thead>
<tbody>
{matrix.agents.map(([agentId, agentName]) => (
<tr key={agentId}>
<td style={{ fontWeight: 600 }}>{agentName}</td>
{matrix.probes.map(([probeId]) => {
const cell = matrix.byProbe.get(probeId)?.find((c) => c.line.agent_id === agentId)
if (!cell) return <td key={probeId} className="r faint">—</td>
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 (
<td key={probeId} className="r">
<button type="button" className={`cell ${on ? 'on' : ''}`} onClick={() => toggle(cell.line.id)} title={on ? '从图里移除' : '加到下面的图里'}>
{cell.rank === 1 ? <Star size={13} /> : null}
<Chip tone={tone}>{fmt(metric, cell.value)}</Chip>
{cell.rank !== null && cell.rank > 1 && cell.gap !== null ? <small className="faint">+{cell.gap.toFixed(0)}%</small> : null}
</button>
</td>
)
})}
</tr>
))}
</tbody>
</table>
<p className="faint" style={{ padding: '4px 12px 8px' }}>
★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
</p>
</div>
<div className="card" style={{ marginTop: 16 }}>
<div className="card-title">{metric.label} 的走势</div>
{compare.data ? <TimeChart t={compare.data.t} series={series} unit={metric.unit === '' ? '' : metric.unit} /> : <Empty>{selected.length ? '加载中…' : '点上面的格子选择线路'}</Empty>}
</div>
</>
)
) : null}
</main>
)
}