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.
221 lines
11 KiB
TypeScript
221 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|