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