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.
This commit is contained in:
justme committed 2026-10-21 16:59:13 +08:00
1 parent 7fe9775f97
commit 08f261293c
2 files changed
+127 -48

No files matched your search

+53 -19
View File
@@ -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)[]) => (
<div className="line-mini-row" key={id}>
<div className="line-mini-direction-head" title={`${proto} ${label}`}>
<span>{proto}</span>
<span className={`line-mini-badge ${levelOf(value)}`}>{text}</span>
</div>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 ${proto} ${label}`}>
<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'}${hover === i ? ' on' : ''}`} />
<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>
)
const direction = (id: string, route: string, title: string, rows: ReturnType<typeof row>[]) => (
<div className="line-mini-direction" key={id}>
<div className="line-mini-route" title={title}>{route}</div>
{rows}
{ticks.length > 0 ? (
<div className="line-mini-axis" aria-hidden="true">
{ticks.map((tick) => (
<span key={tick.pos} style={{ left: `${tick.pos}%` }}>{tick.label}</span>
))}
</div>
) : null}
</div>
)
return (
@@ -89,16 +123,14 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
<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-route" title={`去程:${dialer} → ${listener}`}>{dialer} → {listener}</div>
{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)}
</div>
<div className="line-mini-direction">
<div className="line-mini-route" title={`回程:${listener} → ${dialer}`}>{listener} → {dialer}</div>
{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)}
</div>
{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),
])}
</div>
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
{at !== null ? (
@@ -106,6 +138,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
<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>
@@ -130,6 +163,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
</span>
</div>
</>}
</>
) : null}
</div>
+74 -29
View File
@@ -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 {