Files
MoShiro/web/display/src/pages/Line.tsx
T
justme af385cf688
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful
web: show the adaptive probe-rate audit on the line page [CI]
2026-10-22 10:44:46 +08:00

384 lines
21 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 { 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 { AdaptiveAudit } from '../AdaptiveAudit'
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<MetricGroup, 'score'>; 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.up', fmt: fmtPct, good: 0.5, poor: 3 },
{ group: 'tcp-connect', label: 'TCP 回程重传率', key: 'tcp.retrans.down', 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
/** 没有才是好的指标:整段为 0 时图上显示的说明 */
quiet?: string
}
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: '%', fill: true, quiet: '✓ 这段时间没有丢包', 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: '包', fill: true, quiet: '✓ 这段时间没有连续丢包', 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, quiet: '✓ 这段时间没有重传', series: [{ key: 'tcp.retrans.up', label: '去程', color: '--chart-2' }, { key: 'tcp.retrans.down', label: '回程', color: '--chart-3' }, { key: 'tcp.retrans', label: '合计', color: '--chart-1', dash: [4, 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<MetricGroup>('quic')
const line = useQuery(() => api.get<LineDto>(`/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<TestDto>(`/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: <TimeChart t={s.t} series={list} unit={def.unit} quiet={def.quiet} gaps={gaps} zero={def.zero !== false} height={metricGroup === 'quic' ? 220 : def.wide ? 230 : 190} /> }
})
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
}).filter((g) => g.cards.length > 0)
: [],
[s, gaps, smooth.alpha, metricGroup],
)
const loadingNow = !ready || series.busy
const chartSections = charts.map(({ group, cards }) => (
<section className="chart-section" key={group.id}>
<div className={`grid cols-2 ${group.id === 'quic' ? 'quic-chart-grid' : ''}`}>
{cards.map(({ def, node }) => (
<div className={`card chart-card has-loading ${def.wide || cards.length === 1 ? 'wide' : ''}`} key={def.id}>
<LoadingBar active={loadingNow} />
<h3>{def.title}</h3>
{node}
</div>
))}
</div>
</section>
))
const result = test.data?.result
const peak = result?.metrics?.['tcp.bulk.peak.max']
return (
<main className="page line-page">
<div className="crumbs">
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
<ChevronLeft size={16} />
总览
</Link>
</div>
<section className="hero line-header-surface">
<div className="line-title-row">
<h1>
{line.data?.dialer_name ?? '线路'}
{line.data ? <span className="muted line-peer-name"> → {line.data.name}</span> : null}
</h1>
<div className="row line-version-switch">
{IP_VERSIONS.map((v) => {
const query = new URLSearchParams(params)
query.set('ip_version', v.value)
return (
<Link key={v.value} className={`btn small${(id.endsWith('~ipv6') ? 'ipv6' : 'ipv4') === v.value ? ' primary' : ''}`} to={`/line/${encodeURIComponent(lineIdFor(id, v.value))}?${query.toString()}`}>
{v.label}
</Link>
)
})}
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
</div>
</div>
{line.data ? (
<div className="row wrap line-route-meta">
<Dot tone={line.data.online && line.data.dialer_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
<span className="line-route-status">{line.data.online ? (line.data.dialer_connected ? '在线' : '测量未连接') : '离线'}</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">目标 Agent</span>
<NodeTag info={infos.get(line.data.agent_id)} />
</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">来源 Agent</span>
<NodeTag info={infos.get(line.data.dialer_id)} />
</span>
{line.data.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
{series.loading && !s ? <Spinner /> : null}
</div>
) : null}
<div className="line-range-picker">
<RangePicker
dayStart={period.dayStart}
fromMs={period.fromMs}
toMs={period.toMs}
onChange={changeDay}
onWindow={pickLength}
scale={period.domainMin > 1440 ? 48 : 24}
onScale={setScale}
sinceMs={sinceMs}
nowMs={nowMs}
spanMs={period.spanMs}
toolbar={
<div className="line-toolbar-group line-smoothing">
<span className="line-section-label" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">平滑</span>
<Segmented<EwmaValue>
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)))}
/>
</div>
}
>
<Minimap
domainStart={period.domainStart}
domainMin={period.domainMin}
t={period.overview.t}
values={period.overview.loss}
fromMs={period.fromMs}
toMs={period.toMs}
loading={!ready || period.busy}
onSelect={selectRange}
/>
</RangePicker>
</div>
</section>
<PageError error={line.error ?? series.error} />
{!s && loadingNow && !line.error && !series.error ? (
<div className="line-loading-state has-loading" role="status" aria-live="polite">
<LoadingBar active />
<Spinner />
<span>正在加载线路数据…</span>
</div>
) : null}
{!s && !loadingNow && !series.error && line.data ? <div className="line-empty-state">所选时间段暂无测量数据。</div> : null}
{s ? (
<div className="stack line-metrics" style={{ marginTop: 16 }}>
<section className="line-metric-surface">
<header className="line-metric-header">
<div className="line-metric-heading">
<h2>{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}</h2>
{metricGroup === 'quic' ? <span>往返时延 · 双向丢包 · 抖动</span> : null}
</div>
<Segmented<MetricGroup> options={METRIC_GROUPS} value={metricGroup} onChange={setMetricGroup} />
</header>
{metricGroup === 'quic' ? <>
<div className="line-quic-summary">
<div className="line-combo-card">
<h3>QUIC RTT</h3>
<div className="line-combo-primary"><span>p50</span><strong>{fmtMs(m['rtt.p50'])}</strong></div>
<div className="line-combo-secondary"><span>p95 <b>{fmtMs(m['rtt.p95'])}</b></span><span className="combo-divider">|</span><span>p99 <b>{fmtMs(m['rtt.p99'])}</b></span></div>
</div>
<div className="line-combo-card">
<h3>QUIC Datagram 丢包</h3>
<div className="line-combo-primary"><span>总丢包</span><strong className={`combo-tone-${tone(m['loss.total'], 0.5, 3)}`}>{fmtPct(m['loss.total'])}</strong></div>
<div className="line-combo-secondary"><span>去 {fmtPct(m['loss.up'])}</span><span>/</span><span>回 {fmtPct(m['loss.down'])}</span></div>
<span className="line-combo-badge">最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包</span>
</div>
<div className="line-combo-card">
<h3>QUIC 抖动 p95</h3>
<div className="line-jitter-pair"><span>↑ <b>{fmtMs(m['jitter.up.p95'])}</b></span><span>↓ <b>{fmtMs(m['jitter.down.p95'])}</b></span></div>
</div>
</div>
<div className="line-session-bar" title="会话计数从 来源 Agent 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
<strong className="line-session-title">QUIC 会话与传输</strong>
{line.data?.quic_health ? (() => {
const h = line.data.quic_health!
return <>
<div className="line-session-items">
<span>握手 p95 <b>{fmtMs(h.handshake_p95_ms)}</b></span>
<span>成功率 <b>{fmtPct(h.handshake_success_rate)}</b></span>
<span>超时 <b>{fmtPct(h.handshake_timeout_rate)}</b></span>
<span>失败 <b>{fmtPct(h.handshake_failure_rate)}</b></span>
<span>传输疑似丢包 <b>{fmtPct(h.transport_packet_loss_rate)}</b></span>
<span>路径 MTU <b>{fmtNum(h.path_mtu)} B</b></span>
</div>
</>
})() : <span className="line-session-empty">来源 Agent 尚未上报会话数据</span>}
</div>
</> : null}
{metricGroup !== 'quic' ? <div className="tiles">
{metricGroup === 'score' ? <div className="tile scores-tile has-loading"><LoadingBar active={loadingNow} /><span className="k">评分</span><ScoreChips metrics={m} /></div> : null}
{TILES.filter((t) => t.group === metricGroup && (!t.optional || m[t.key] != null)).map((t) => (
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
))}
</div> : null}
<div className="line-data-notes">
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['quic_ping'])} · TCP {fmtNum(s.summary.samples['tcp_ping'])}</span>
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
</div>
{chartSections}
</section>
<AdaptiveAudit lineId={id} />
{test.data && result ? (
<div className="card">
<div className="card-title">最近一次测试 · {test.data.plan}</div>
<div className="grid stats">
{peak !== undefined ? <Stat label="吞吐峰值" value={fmtMbps(peak)} /> : null}
{result.metrics['connect.success'] !== undefined ? <Stat label="建连成功率" value={fmtPct(result.metrics['connect.success'], 1)} /> : null}
{result.metrics['connect.tls_ms.p95'] !== undefined ? <Stat label="TLS 握手 p95" value={fmtMs(result.metrics['connect.tls_ms.p95'])} /> : null}
{result.metrics['hy2.sustainable_mbps'] !== undefined ? <Stat label="Hy2 可持续码率" value={fmtMbps(result.metrics['hy2.sustainable_mbps'])} /> : null}
{result.metrics['hy2.qos_onset_mbps'] !== undefined ? <Stat label="限速起点" value={fmtMbps(result.metrics['hy2.qos_onset_mbps'])} /> : null}
</div>
<p className="faint" style={{ marginTop: 12 }}>
{fmtTime(test.data.finished_at)}({fmtAgo(test.data.finished_at)})
</p>
</div>
) : null}
</div>
) : null}
</main>
)
}