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 }, ] 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) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) interface StripProps { /** 左上:什么延迟(UDP 延迟 p50)。 */ rttLabel: string rtt: number | null | undefined /** 右上:丢包还是重传。 */ badLabel: string bad: number | null | undefined badHint?: string /** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */ t: number[] badSeries: (number | null)[] ready: boolean /** 提示里这一格的「丢包」叫什么。 */ tipBad: string /** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */ cells: number stepText: string hoursText: string /** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */ hover: number | null } /** * 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据) * 尽量轻。 */ function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) { return (
{rttLabel} {fmtMs(rtt)} {badLabel} {fmtPct(bad)}
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => ( ))}
) } interface Props { lineId: string range: OverviewRange /** 近 3 小时的汇总(左上、右上的数字)。 */ m: Record } /** 一条线路近一段时间的 UDP 和 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: 'latest', step: info.step, preset: 'udp,tcp', metrics: 'loss.total,rtt.p50,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] ?? [] // 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP const [hover, setHover] = useState(null) const last = useRef(null) // 提示收回去的动画期间,内容还要留着 if (hover !== null) last.current = hover const shown = hover ?? last.current // 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上 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} 分钟`, hoursText: `${info.hours} 小时`, hover } const at = shown !== null && t[shown] !== undefined ? shown : null return (
setHover(null)}>
{at !== null ? ( <> {clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
UDP 丢包 {fmtPct(col('loss.total')[at])} 延迟 p50 {fmtMs(col('rtt.p50')[at])}
TCP 重传 {fmtPct(col('tcp.retrans')[at])} RTT p50 {fmtMs(col('tcp.rtt.p50')[at])}
) : null}
) }