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 ? (
+
+ {ticks.map((tick) => (
+ {tick.label}
+ ))}
+
+ ) : 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 {