Files
MoShiro/web/display/src/LineStrips.tsx
T
justme 8efc16f341
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: group the QUIC and TCP rows of a line card by protocol [CI]
2026-10-22 07:11:33 +08:00

180 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 { useRef, useState, type CSSProperties, type MouseEvent } from 'react'
import { api } from '@shared/api/client'
import type { SeriesDto } from '@shared/api/types'
import { fmtMs, fmtPct } from '@shared/format'
import { useQuery } from '@shared/hooks'
/** 总览卡片可以看的时长,以及每一格的长度(格数约 36~48,太多就挤了)。 */
export const OVERVIEW_RANGES = [
{ value: '3h', label: '近 3 小时', hours: 3, step: '5m', stepMin: 5 },
{ value: '6h', label: '近 6 小时', hours: 6, step: '10m', stepMin: 10 },
{ value: '12h', label: '近 12 小时', hours: 12, step: '15m', stepMin: 15 },
{ value: '24h', label: '近 24 小时', hours: 24, step: '30m', stepMin: 30 },
{ value: '48h', label: '近 48 小时', hours: 48, step: '1h', stepMin: 60 },
{ value: '96h', label: '近 96 小时', hours: 96, step: '2h', stepMin: 120 },
] as const
export type OverviewRange = (typeof OVERVIEW_RANGES)[number]['value']
type Level = 'ok' | 'warn' | 'bad' | 'none'
const levelOf = (loss: number | null | undefined): Level => (loss === null || loss === undefined ? 'none' : loss <= 0.5 ? 'ok' : loss < 3 ? 'warn' : 'bad')
export const clock = (ms: number, showDate: boolean) => new Date(ms).toLocaleString('zh-CN', { ...(showDate ? { month: '2-digit', day: '2-digit' } : {}), hour: '2-digit', minute: '2-digit', hour12: false })
const hhmm = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
/** 时间轴刻度:在整点(或整半点、整几小时)的格子边界上画一条细线并标时间,最多约 6 个;零点那条标日期。 */
export function axisTicks(t: number[], stepMin: number, hours: number): { pos: number; label: string }[] {
if (t.length === 0) return []
const interval = [30, 60, 120, 180, 360, 720, 1440].find((m) => (hours * 60) / m <= 6) ?? 1440
const offset = new Date(t[0]).getTimezoneOffset() * 60_000
const ticks: { pos: number; label: string }[] = []
for (let i = 0; i <= t.length; i++) {
const at = i < t.length ? t[i] : t[t.length - 1] + stepMin * 60_000
if (Math.round((at - offset) / 60_000) % interval !== 0) continue
const day = new Date(at)
const label = day.getHours() === 0 && day.getMinutes() === 0 ? `${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}` : hhmm(at)
ticks.push({ pos: (i / t.length) * 100, label })
}
return ticks
}
interface Props {
lineId: string
/** 拨号方和监听方的名字:去程是拨号方到监听方,回程反过来。 */
dialer: string
listener: string
range: OverviewRange
/** 所选时间范围的汇总(左上、右上的数字)。 */
m: Record<string, number | null | undefined>
}
/** 一条线路的 QUIC/TCP 摘要:RTT 是往返(⇄);每个方向一行,写明是「拨号方 → 监听方」还是反过来;QUIC 看各方向丢包,TCP 看各方向重传,都配质量点阵。 */
export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
const series = useQuery(
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'now', step: info.step, preset: 'quic,tcp', metrics: 'loss.up,loss.down,loss.total,rtt.p50,rtt.p95,rtt.p99,tcp.retrans.up,tcp.retrans.down,tcp.rtt.p50' }),
[lineId, info.value],
30_000,
)
const s = series.data
const t = s?.t ?? []
const col = (key: string): (number | null)[] => s?.values[key] ?? []
// 悬停的那一格:QUIC 上下行和 TCP 共用时间轴。
const [hover, setHover] = useState<number | null>(null)
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
if (hover !== null) last.current = hover
const shown = hover ?? last.current
// 整块线路区域都能触发:按鼠标横向位置对应到采样格,不用精确指到小格上。
const box = useRef<HTMLDivElement>(null)
const onMove = (e: MouseEvent<HTMLDivElement>) => {
const bars = box.current?.querySelector('.bars')
if (!s || t.length < 1 || !bars) return
const r = bars.getBoundingClientRect()
setHover(Math.min(t.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * t.length))))
}
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: info.stepMin >= 60 ? `${info.stepMin / 60} 小时` : `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
const at = shown !== null && t[shown] !== undefined ? shown : null
const lossUp = col('loss.up')
const lossDown = col('loss.down')
const retransUp = col('tcp.retrans.up')
const retransDown = col('tcp.retrans.down')
// 最后一格是还没走完的时间段:统计要等这一格结束才有值,不能当成「没有数据」
const lastIndex = t.length - 1
const pendingLast = Boolean(s?.edge_partial.last) && lastIndex >= 0 && [lossUp, lossDown, retransUp, retransDown].every((c) => c[lastIndex] === null || c[lastIndex] === undefined)
const ticks = axisTicks(t, info.stepMin, info.hours)
const lossText = (v: number | null | undefined) => (v === null || v === undefined ? '丢包 —' : `${fmtPct(v)} 丢包`)
const retransText = (v: number | null | undefined) => (v === null || v === undefined ? '重传 —' : `${fmtPct(v)} 重传`)
const cells = s ? Math.max(t.length, 1) : common.cells
const cellsOf = (values: (number | null)[]) => (s ? values : Array.from({ length: common.cells }, () => null))
// 一个方向下的一行:协议名、质量点阵和数值徽标,三列和同一方向的另一行对齐。
const row = (id: string, gridRow: number, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => (
<div className="line-mini-row" key={id}>
<span className="line-mini-proto" style={{ gridRow }} title={`${proto} ${label}`}>{proto}</span>
<div className="bars" style={{ gridRow, '--cells': cells } as CSSProperties} role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 ${proto} ${label}`}>
{cellsOf(values).map((v, i) => (
<span key={`${id}-${t[i] ?? i}`} className={`bar ${s ? levelOf(v) : 'none'}${pendingLast && i === lastIndex ? ' pending' : ''}${hover === i ? ' on' : ''}`} />
))}
</div>
<span className={`line-mini-badge ${levelOf(value)}`} style={{ gridRow }}>{text}</span>
</div>
)
// 一个协议一组:QUIC 的去程和回程放在一起,TCP 的放在一起;每个方向上面一行写明是谁到谁,组底下一条时间轴
const group = (id: string, proto: string, label: (dir: string) => string, entries: { route: string; dir: 'up' | 'down'; value: number | null | undefined; text: string; values: (number | null)[] }[]) => (
<div className="line-mini-direction" key={id}>
{entries.flatMap((e, k) => [
<div className="line-mini-route" key={`${id}-${e.dir}-route`} style={{ gridRow: k * 2 + 1 }} title={`${e.dir === 'up' ? '去程' : '回程'}:${e.route}`}>
{e.route}
</div>,
row(`${id}-${e.dir}`, k * 2 + 2, proto, label(e.dir === 'up' ? '去程' : '回程'), e.value, e.text, e.values),
])}
{ticks.length > 0 ? (
<div className="line-mini-axis" aria-hidden="true" style={{ gridRow: entries.length * 2 + 1 }}>
{ticks.map((tick) => (
<span key={tick.pos} style={{ left: `${tick.pos}%` }}>{tick.label}</span>
))}
</div>
) : null}
</div>
)
const up = `${dialer} → ${listener}`
const down = `${listener} → ${dialer}`
return (
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
<div className="line-mini-stack">
<div className="line-mini-quic-head" title={`RTT 是往返。QUIC p50 ${fmtMs(m['rtt.p50'])} · p95 ${fmtMs(m['rtt.p95'])} · p99 ${fmtMs(m['rtt.p99'])};TCP p50 ${fmtMs(m['tcp.rtt.p50'])}`}>
<span>RTT ⇄</span>
<span className="line-mini-rtt">QUIC <strong className="num">{fmtMs(m['rtt.p50'])}</strong></span>
<span className="line-mini-rtt">TCP <strong className="num">{fmtMs(m['tcp.rtt.p50'])}</strong></span>
<small>悬停看 p95 / p99</small>
{series.error ? <span className="line-mini-badge bad" title={`上一次取数失败:${series.error.message}`}>数据未更新</span> : null}
</div>
{group('quic', 'QUIC', (d) => `${d}丢包`, [
{ route: up, dir: 'up', value: m['loss.up'], text: lossText(m['loss.up']), values: lossUp },
{ route: down, dir: 'down', value: m['loss.down'], text: lossText(m['loss.down']), values: lossDown },
])}
{group('tcp', 'TCP', (d) => `${d}重传`, [
{ route: up, dir: 'up', value: m['tcp.retrans.up'], text: retransText(m['tcp.retrans.up']), values: retransUp },
{ route: down, dir: 'down', value: m['tcp.retrans.down'], text: retransText(m['tcp.retrans.down']), values: retransDown },
])}
</div>
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
{at !== null ? (
<>
<b className="pop-time">
{clock(t[at], info.hours >= 24)}–{clock(t[at] + info.stepMin * 60_000, info.hours >= 24)}
</b>
{pendingLast && at === lastIndex ? <span className="pop-note">这一格还没走完,结束后才有统计</span> : <>
<div className="pop-group">
<span className="pop-name">QUIC Datagram</span>
<span>RTT p50 <em>{fmtMs(col('rtt.p50')[at])}</em></span>
<span>RTT p95 <em>{fmtMs(col('rtt.p95')[at])}</em></span>
<span>RTT p99 <em>{fmtMs(col('rtt.p99')[at])}</em></span>
<span>
去程丢包 <em className={levelOf(col('loss.up')[at])}>{fmtPct(col('loss.up')[at])}</em>
</span>
<span>
回程丢包 <em className={levelOf(col('loss.down')[at])}>{fmtPct(col('loss.down')[at])}</em>
</span>
</div>
<div className="pop-group">
<span className="pop-name">TCP</span>
<span>
去程重传 <em className={levelOf(col('tcp.retrans.up')[at])}>{fmtPct(col('tcp.retrans.up')[at])}</em>
</span>
<span>
回程重传 <em className={levelOf(col('tcp.retrans.down')[at])}>{fmtPct(col('tcp.retrans.down')[at])}</em>
</span>
<span>
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
</span>
</div>
</>}
</>
) : null}
</div>
</div>
)
}