feat(display): explain QUIC state and compact panels [CI]
ci/woodpecker/push/dev-image Pipeline is pending
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was canceled

This commit is contained in:
justme committed 2026-10-10 14:17:43 +08:00
1 parent efb50d0efe
commit 85c2043f2f
4 files changed
+96 -52

No files matched your search

+15 -9
View File
@@ -6,9 +6,9 @@ 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: '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 },
] as const
export type OverviewRange = (typeof OVERVIEW_RANGES)[number]['value']
@@ -68,32 +68,38 @@ export function LineStrips({ lineId, range, m }: Props) {
<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>
</div>
<div className="line-mini-direction-row" title={`去程 Probe → Agent 丢包 ${fmtPct(up)}`}>
<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>
<span className={`line-mini-badge ${levelOf(up)}`}>{upText}</span>
</div>
<div className="line-mini-direction-row" title={`回程 Agent → Probe 丢包 ${fmtPct(down)}`}>
<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>
<span className={`line-mini-badge ${levelOf(down)}`}>{downText}</span>
</div>
<div className="line-mini-tcp-row" title={`TCP RTT p50 ${fmtMs(m['tcp.rtt.p50'])}`}>
<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>
<span className={`line-mini-badge ${retransTone}`}>{retransText}</span>
</div>
</div>
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
+58 -16
View File
@@ -116,6 +116,29 @@
margin-top: 8px;
}
.line-loading-state,
.line-empty-state {
display: flex;
min-height: 58px;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 16px;
border: 1px solid var(--glass-ring);
border-radius: var(--r-card);
background: color-mix(in srgb, var(--glass-bg) 75%, transparent);
color: var(--app-ink-muted);
font-size: 13px;
}
.line-loading-state .loading-bar {
height: 2px;
}
.line-empty-state {
color: var(--app-ink-faint);
}
.line-page .faint,
.line-page .chart-hint,
.line-page .chart-section h2,
@@ -1359,15 +1382,15 @@
}
.line-mini-quic-head > span,
.line-mini-direction-row > span:first-child,
.line-mini-tcp-row > span:first-child {
.line-mini-direction-head > span:first-child,
.line-mini-tcp-head > span:first-child {
color: var(--app-ink-muted);
font-size: 10px;
font-weight: 600;
}
.line-mini-quic-head > strong,
.line-mini-tcp-row > strong {
.line-mini-tcp-head > strong {
color: var(--app-ink);
font-size: 14px;
font-weight: 700;
@@ -1381,32 +1404,40 @@
white-space: nowrap;
}
.line-mini-direction-row {
.line-mini-direction,
.line-mini-tcp {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 66px;
align-items: center;
gap: 5px;
gap: 3px;
min-width: 0;
}
.line-mini-direction-row > span:first-child,
.line-mini-tcp-row > span:first-child {
.line-mini-direction-head,
.line-mini-tcp-head {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
line-height: 1.25;
}
.line-mini-direction-head > span:first-child,
.line-mini-tcp-head > span:first-child {
font-weight: 500;
white-space: nowrap;
}
.line-mini-tcp-row {
display: grid;
grid-template-columns: 34px 46px minmax(0, 1fr) max-content;
align-items: center;
gap: 5px;
min-width: 0;
.line-mini-direction-head .line-mini-badge,
.line-mini-tcp-head .line-mini-badge {
margin-left: auto;
}
.line-mini-tcp {
margin-top: 2px;
padding-top: 5px;
border-top: 1px solid var(--app-outline-soft);
}
.line-mini-tcp-row > strong {
.line-mini-tcp-head > strong {
font-size: 12px;
}
@@ -1693,6 +1724,7 @@
.agent-head .flag {
flex: none;
width: 24px;
max-width: 24px;
height: 18px;
border-radius: 3px;
object-fit: cover;
@@ -1801,6 +1833,16 @@
background: color-mix(in srgb, var(--app-ink) 7%, transparent);
}
.agent-card .line-link.disconnected,
.agent-card .line-link.disconnected:hover {
filter: grayscale(1);
opacity: 0.62;
}
.line-range.disconnected-label {
color: var(--app-ink-faint);
}
/* 线路详情、对比等页面的卡片和指标块:同样的亚克力,一层压一层 */
.card:not(.agent-card),
+6 -19
View File
@@ -286,29 +286,16 @@ export function Line() {
<PageError error={line.error ?? series.error} />
{!s && (loadingNow || !line.error) && !series.error ? (
// 还没有数据:先摆出占位,每块上面有进度条
<div className="stack" style={{ marginTop: 24 }}>
<div className="tiles">
{Array.from({ length: 12 }, (_, i) => (
<div className="skeleton has-loading" key={i}>
{!s && loadingNow && !line.error && !series.error ? (
<div className="line-loading-state has-loading" role="status" aria-live="polite">
<LoadingBar active />
</div>
))}
</div>
<div className="skeleton chart has-loading">
<LoadingBar active />
</div>
<div className="grid cols-2">
{[0, 1].map((i) => (
<div className="skeleton chart has-loading" key={i}>
<LoadingBar active />
</div>
))}
</div>
<Spinner />
<span>正在加载线路数据…</span>
</div>
) : null}
{!s && !loadingNow && !series.error && line.data ? <div className="line-empty-state">所选时间段暂无测量数据。</div> : null}
{s ? (
<div className="stack line-metrics" style={{ marginTop: 16 }}>
<section className="line-metric-surface">
+17 -8
View File
@@ -141,8 +141,18 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const probeInfo = infos.get(l.probe_id)
const agentInfo = infos.get(l.agent_id)
const rangeLabel = OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range
const connectionIssue = !l.online
? 'Agent 离线'
: l.quic_health?.state === 'retry_wait'
? 'QUIC 拨号失败'
: !l.probe_connected
? '测量通道未建立'
: l.quic_health && !l.quic_health.connected
? 'QUIC 正在连接'
: null
return (
<Link to={`/line/${encodeURIComponent(l.id)}`} className="line-link line-card">
<Link to={`/line/${encodeURIComponent(l.id)}`} className={`line-link line-card${connectionIssue ? ' disconnected' : ''}`}>
<div className="line-top">
<span className="line-name" title={[l.probe_name, probeInfo?.tag || probeInfo?.version].filter(Boolean).join(' · ')}>
{probeInfo?.country ? <Flag code={probeInfo.country} /> : null}
@@ -152,13 +162,12 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang
{agentInfo?.country ? <Flag code={agentInfo.country} /> : null}
<span className="name">{l.name}</span>
</span>
{!l.probe_connected && l.online ? (
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 控制通道和 QUIC Datagram 数据通道,线路或运营商对它们的处理可能和 ping 不一样。">
<Chip tone="warn">测量通道未建立</Chip>
<span
className={connectionIssue ? 'line-range disconnected-label' : 'faint line-range'}
title={l.quic_health?.state === 'retry_wait' ? `Probe 正在等待退避重试${l.quic_health.retry_in_ms ? `(约 ${Math.ceil(l.quic_health.retry_in_ms / 1000)} 秒)` : ''}。` : !l.probe_connected && l.online ? 'Agent 在线,但 Probe 尚未建立测量会话。' : undefined}
>
{connectionIssue ? `${connectionIssue} · ${rangeLabel}` : rangeLabel}
</span>
) : (
<span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
)}
</div>
<LineStrips lineId={l.id} range={range} m={l.summary.metrics} />
</Link>
@@ -254,7 +263,7 @@ export function Overview() {
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setCarrier(''); setParams(next, { replace: true }) }} />
{groups.length > 1 ? <Segmented<'' | CarrierKey> options={[{ value: '', label: '全部' }, ...groups.map((g) => ({ value: g.key, label: g.name }))]} value={carrier} onChange={setCarrier} /> : null}
<span className="right row">
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} value={range} onChange={setRange} />
<Segmented
options={[
{ value: 'name' as const, label: '名称' },