diff --git a/web/display/src/LineStrips.tsx b/web/display/src/LineStrips.tsx index d5f7ec6..1d378b4 100644 --- a/web/display/src/LineStrips.tsx +++ b/web/display/src/LineStrips.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type MouseEvent } from 'react' +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' @@ -22,6 +22,24 @@ const levelOf = (loss: number | null | undefined): Level => (loss === null || lo 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 个;零点那条标日期。 */ +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 /** 拨号方和监听方的名字:去程是拨号方到监听方,回程反过来。 */ @@ -62,21 +80,37 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) { 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, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => ( + // 一个方向下的一行:协议名、质量点阵和数值徽标,三列和同一方向的另一行对齐。 + const row = (id: string, gridRow: number, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => (
-
- {proto} - {text} -
-
+ {proto} +
{cellsOf(values).map((v, i) => ( - + ))}
+ {text} +
+ ) + const direction = (id: string, route: string, title: string, rows: ReturnType[]) => ( +
+
{route}
+ {rows} + {ticks.length > 0 ? ( + + ) : null}
) return ( @@ -89,16 +123,14 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) { 悬停看 p95 / p99 {series.error ? 数据未更新 : null}
-
-
{dialer} → {listener}
- {row('up-quic', 'QUIC', '去程丢包', m['loss.up'], lossText(m['loss.up']), lossUp)} - {row('up-tcp', 'TCP', '去程重传', m['tcp.retrans.up'], retransText(m['tcp.retrans.up']), retransUp)} -
-
-
{listener} → {dialer}
- {row('down-quic', 'QUIC', '回程丢包', m['loss.down'], lossText(m['loss.down']), lossDown)} - {row('down-tcp', 'TCP', '回程重传', m['tcp.retrans.down'], retransText(m['tcp.retrans.down']), retransDown)} -
+ {direction('up', `${dialer} → ${listener}`, `去程:${dialer} → ${listener}`, [ + row('up-quic', 2, 'QUIC', '去程丢包', m['loss.up'], lossText(m['loss.up']), lossUp), + row('up-tcp', 3, 'TCP', '去程重传', m['tcp.retrans.up'], retransText(m['tcp.retrans.up']), retransUp), + ])} + {direction('down', `${listener} → ${dialer}`, `回程:${listener} → ${dialer}`, [ + row('down-quic', 2, 'QUIC', '回程丢包', m['loss.down'], lossText(m['loss.down']), lossDown), + row('down-tcp', 3, 'TCP', '回程重传', m['tcp.retrans.down'], retransText(m['tcp.retrans.down']), retransDown), + ])}
{at !== null ? ( @@ -106,6 +138,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) { {clock(t[at], info.hours >= 24)}–{clock(t[at] + info.stepMin * 60_000, info.hours >= 24)} + {pendingLast && at === lastIndex ? 这一格还没走完,结束后才有统计 : <>
QUIC Datagram RTT p50 {fmtMs(col('rtt.p50')[at])} @@ -130,6 +163,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) { RTT p50 {fmtMs(col('tcp.rtt.p50')[at])}
+ } ) : null}
diff --git a/web/display/src/display.css b/web/display/src/display.css index 46ab000..a7d7503 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -1374,6 +1374,11 @@ } .line-mini-stack { + --lm-label: 32px; + --lm-badge: 78px; + --lm-gap: 8px; + + position: relative; display: flex; flex-direction: column; gap: 4px; @@ -1387,8 +1392,7 @@ line-height: 1.2; } -.line-mini-quic-head > span, -.line-mini-direction-head > span:first-child { +.line-mini-quic-head > span { color: var(--app-ink-muted); font-size: 10px; font-weight: 600; @@ -1410,28 +1414,14 @@ .line-mini-direction { display: grid; - gap: 3px; + grid-template-columns: var(--lm-label) minmax(0, 1fr) var(--lm-badge); + gap: 4px var(--lm-gap); + align-items: center; min-width: 0; } -.line-mini-direction-head { - display: flex; - align-items: baseline; - gap: 6px; - min-width: 0; - line-height: 1.25; -} - -.line-mini-direction-head > span:first-child { - font-weight: 500; - white-space: nowrap; -} - -.line-mini-direction-head .line-mini-badge { - margin-left: auto; -} - .line-mini-route { + grid-column: 1 / -1; min-width: 0; overflow: hidden; color: var(--app-ink); @@ -1442,9 +1432,59 @@ } .line-mini-row { - display: grid; - gap: 3px; - min-width: 0; + display: contents; +} + +.line-mini-proto { + grid-column: 1; + color: var(--app-ink-muted); + font-size: 10px; + font-weight: 600; +} + +.line-mini-row .bars { + position: relative; + z-index: 1; + grid-column: 2; +} + +.line-mini-row .line-mini-badge { + grid-column: 3; + justify-self: end; +} + +.line-mini-axis { + position: relative; + grid-column: 2; + grid-row: 4; + height: 14px; + border-top: 1px solid var(--app-ink-faint); + color: var(--app-ink-muted); + font-size: 10px; + line-height: 1; + pointer-events: none; +} + +.line-mini-axis > span { + position: absolute; + top: 0; + padding-top: 5px; + transform: translateX(-50%); + white-space: nowrap; +} + +.line-mini-axis > span::before { + position: absolute; + top: -1px; + left: 50%; + height: 5px; + border-left: 1px solid var(--app-ink-muted); + content: ''; +} + +.pop-note { + color: var(--app-ink-muted); + font-size: 11px; } .line-mini-rtt { @@ -1488,23 +1528,28 @@ } .bars { - display: flex; + display: grid; + grid-template-columns: repeat(var(--cells, 36), minmax(0, 1fr)); min-width: 0; - gap: 1.5px; - height: 8px; + gap: 2px; + height: 10px; } +/* 每格等宽等高;没有数据和还没走完的格子是灰色 */ .bar { - flex: 1; min-width: 0; border-radius: 2px; background: var(--app-idle); - opacity: 0.35; + opacity: 0.4; +} + +.bar.pending { + opacity: 0.4; } .bar.ok { background: var(--app-ok); - opacity: 0.9; + opacity: 1; } .bar.warn {