Files
MoShiro/web/display/src/LineStrips.tsx
T
justme cc64df3e70
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline failed
[CI] Keep line cards visible and time windows current; add 24h to 96h ranges
2026-10-20 20:44:04 +08:00

142 lines
7.7 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 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<string, number | null | undefined>
}
/** 一条线路的 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<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,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 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 (
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
<div className="line-mini-stack">
<div className="line-mini-quic-head" title={`QUIC RTT p50 ${fmtMs(m['rtt.p50'])} · p95 ${fmtMs(m['rtt.p95'])} · p99 ${fmtMs(m['rtt.p99'])}`}>
<span>QUIC</span><strong className="num">{fmtMs(m['rtt.p50'])}</strong><small>悬停看 p95 / p99</small>
{series.error ? <span className="line-mini-badge bad" title={`上一次取数失败:${series.error.message}`}>数据未更新</span> : null}
</div>
<div className="line-mini-direction">
<div className="line-mini-direction-head" title={`去程 Probe → Agent 丢包 ${fmtPct(up)}`}>
<span>去程</span>
<span className={`line-mini-badge ${levelOf(up)}`}>{upText}</span>
</div>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的去程丢包`}>
{(s ? lossUp : Array.from({ length: common.cells }, () => null)).map((value, i) => (
<span key={`up-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
</div>
<div className="line-mini-direction">
<div className="line-mini-direction-head" title={`回程 Agent → Probe 丢包 ${fmtPct(down)}`}>
<span>回程</span>
<span className={`line-mini-badge ${levelOf(down)}`}>{downText}</span>
</div>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的回程丢包`}>
{(s ? lossDown : Array.from({ length: common.cells }, () => null)).map((value, i) => (
<span key={`down-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
</div>
<div className="line-mini-tcp">
<div className="line-mini-tcp-head" title={`TCP RTT p50 ${fmtMs(m['tcp.rtt.p50'])}`}>
<span>TCP</span><strong className="num">{fmtMs(m['tcp.rtt.p50'])}</strong>
<span className={`line-mini-badge ${retransTone}`}>{retransText}</span>
</div>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 TCP 重传`}>
{(s ? retrans : Array.from({ length: common.cells }, () => null)).map((value, i) => (
<span key={`retrans-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
</div>
</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>
<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')[at])}>{fmtPct(col('tcp.retrans')[at])}</em>
</span>
<span>
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
</span>
</div>
</>
) : null}
</div>
</div>
)
}