import { useRef, useState, 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') 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 }) interface Props { lineId: string range: OverviewRange /** 所选时间范围的汇总(左上、右上的数字)。 */ m: Record } /** 一条线路的 QUIC/TCP 双行摘要,各自显示时延、质量点阵和丢包/重传率。 */ export function LineStrips({ lineId, range, m }: Props) { const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0] const series = useQuery( () => api.get(`/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,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(null) const last = useRef(null) // 提示收回去的动画期间,内容还要留着 if (hover !== null) last.current = hover const shown = hover ?? last.current // 整块线路区域都能触发:按鼠标横向位置对应到采样格,不用精确指到小格上。 const box = useRef(null) const onMove = (e: MouseEvent) => { 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 retrans = col('tcp.retrans') const up = m['loss.up'] const down = m['loss.down'] const upText = up === null || up === undefined ? '丢包 —' : `${fmtPct(up)} 丢包` const downText = down === null || down === undefined ? '丢包 —' : `${fmtPct(down)} 丢包` const retransValue = m['tcp.retrans'] const retransTone = levelOf(retransValue) const retransText = retransValue === null || retransValue === undefined ? '重传 —' : `${fmtPct(retransValue)} 重传` return (
setHover(null)}>
QUIC{fmtMs(m['rtt.p50'])}悬停看 p95 / p99 {series.error ? 数据未更新 : null}
去程 {upText}
{(s ? lossUp : Array.from({ length: common.cells }, () => null)).map((value, i) => ( ))}
回程 {downText}
{(s ? lossDown : Array.from({ length: common.cells }, () => null)).map((value, i) => ( ))}
TCP{fmtMs(m['tcp.rtt.p50'])} {retransText}
{(s ? retrans : Array.from({ length: common.cells }, () => null)).map((value, i) => ( ))}
{at !== null ? ( <> {clock(t[at], info.hours >= 24)}–{clock(t[at] + info.stepMin * 60_000, info.hours >= 24)}
QUIC Datagram RTT p50 {fmtMs(col('rtt.p50')[at])} RTT p95 {fmtMs(col('rtt.p95')[at])} RTT p99 {fmtMs(col('rtt.p99')[at])} 去程丢包 {fmtPct(col('loss.up')[at])} 回程丢包 {fmtPct(col('loss.down')[at])}
TCP 重传 {fmtPct(col('tcp.retrans')[at])} RTT p50 {fmtMs(col('tcp.rtt.p50')[at])}
) : null}
) }