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
+101 -57

No files matched your search

+7 -20
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}>
<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>
{!s && loadingNow && !line.error && !series.error ? (
<div className="line-loading-state has-loading" role="status" aria-live="polite">
<LoadingBar active />
<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">
+18 -9
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>
) : (
<span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
)}
<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>
</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: '名称' },