Files
MoShiro/web/display/src/pages/Compare.tsx
T
justme 9f5a6cf77a analysis: split TCP retransmissions into forward and return, and show both on the line cards
tcp.retrans.up counts retransmitted segments sent by the dialing side, tcp.retrans.down those sent by the listening side. The cards show RTT as a round trip (⇄) with forward (→) and return (←) rows for QUIC loss and TCP retransmission.
2026-10-21 16:26:34 +08:00

221 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Star } from 'lucide-react'
import { useMemo } from 'react'
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
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: 'QUIC 丢包', unit: '%', better: 'lower' },
{ id: 'loss.up', label: 'QUIC 去程丢包', unit: '%', better: 'lower' },
{ id: 'loss.down', label: 'QUIC 回程丢包', unit: '%', better: 'lower' },
{ id: 'loss.run.up_max', label: 'QUIC 去程最长连续丢包', unit: '', better: 'lower' },
{ id: 'loss.run.down_max', label: 'QUIC 回程最长连续丢包', unit: '', better: 'lower' },
{ id: 'jitter.up.p95', label: 'QUIC 去程抖动', unit: 'ms', better: 'lower' },
{ id: 'jitter.down.p95', label: 'QUIC 回程抖动', 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: 'tcp.retrans.up', label: 'TCP 去程重传', unit: '%', better: 'lower' },
{ id: 'tcp.retrans.down', 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
/** 在这一行(同一个来源 Agent)里的名次,从 1 开始;没有数据是 null。 */
rank: number | null
/** 比这一列最好的差多少(百分比);最好的是 0。 */
gap: number | null
}
export function Compare() {
const [params, setParams] = useSearchParams()
const ipVersion = ipVersionOf(params.get('ip_version'))
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((id) => id && id.endsWith('~ipv6') === (ipVersion === 'ipv6')), [params, ipVersion])
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id, ip_version: ipVersion }), [range, metric.id, ipVersion], 15_000)
// 矩阵:行是来源 Agent,列是目标 Agent;每一行内部排名(同一个 来源 Agent 看出去,哪个 Agent 的线路好)
const matrix = useMemo(() => {
const lines = (overview.data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
const dialers = [...new Map(lines.map((l) => [l.dialer_id, l.dialer_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 byDialer = new Map<string, Cell[]>()
for (const [dialerId] of dialers) {
const cells: Cell[] = lines
.filter((l) => l.dialer_id === dialerId)
.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
})
byDialer.set(dialerId, cells)
}
return { dialers, agents, byDialer }
}, [overview.data, metric, ipVersion])
// 默认选每个 来源 Agent 里最好的那条线路(最多 6 条)
const defaults = useMemo(() => {
const ids: string[] = []
for (const [dialerId] of matrix.dialers) {
const best = matrix.byDialer.get(dialerId)?.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.dialer_name)]))
const series: ChartSeries[] = selected.map((id, i) => ({ label: names.get(id) ?? id, values: compare.data?.values[id] ?? [], color: COLORS[i % COLORS.length] }))
// 每个 来源 Agent 的最佳线路
const winners = matrix.dialers.map(([dialerId, dialerName]) => ({ dialerName, cell: matrix.byDialer.get(dialerId)?.find((c) => c.rank === 1) }))
return (
<main className="page">
<section className="hero">
<h1>线路对比</h1>
<p className="sub">从每个来源 Agent 出发,对比各目标 Agent 的线路质量。</p>
<div className="toolbar">
<IpVersionPicker value={ipVersion} onChange={(v) => update({ ip_version: v, lines: null })} />
<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.dialerName} className="card winner">
<span className="faint">从 {w.dialerName} 看出去最好</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>来源 \ 目标</th>
{matrix.agents.map(([id, name]) => (
<th key={id} className="r">
{name}
</th>
))}
</tr>
</thead>
<tbody>
{matrix.dialers.map(([dialerId, dialerName]) => (
<tr key={dialerId}>
<td style={{ fontWeight: 600 }}>{dialerName}</td>
{matrix.agents.map(([agentId]) => {
const cell = matrix.byDialer.get(dialerId)?.find((c) => c.line.agent_id === agentId)
if (!cell) return <td key={agentId} 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={agentId} 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' }}>
★ 是这一行(同一个来源 Agent)里最好的;点格子把线路加到下面的图里,最多 {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>
)
}