From 08f261293cfbebddfe9444aaa96212621ca96f52 Mon Sep 17 00:00:00 2001 From: justme Date: Wed, 21 Oct 2026 16:59:13 +0800 Subject: [PATCH] web: equal-width cells, grey in-progress bucket and a time axis under each direction on the line cards Rows are laid out as protocol, cells and value on one line per metric. Each direction gets its own axis with ticks on the hour; cells without data and the bucket still being filled are grey. --- web/display/src/LineStrips.tsx | 72 +++++++++++++++++------ web/display/src/display.css | 103 +++++++++++++++++++++++---------- 2 files changed, 127 insertions(+), 48 deletions(-) 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 {