From b25b111134e07280041c068c258da47bd9a36894 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 10 Oct 2026 03:00:23 +0800 Subject: [PATCH] feat(display): add compact QUIC and host health views --- web/admin/src/pages/AgentDetail.tsx | 2 +- web/admin/src/pages/Probes.tsx | 14 +- web/display/src/App.tsx | 2 + web/display/src/LineStrips.tsx | 116 +++-- web/display/src/Minimap.tsx | 3 +- web/display/src/display.css | 633 +++++++++++++++++++++++++++- web/display/src/pages/Compare.tsx | 8 +- web/display/src/pages/Host.tsx | 89 ++++ web/display/src/pages/Line.tsx | 257 +++++------ web/display/src/pages/Overview.tsx | 10 +- web/display/src/periods.ts | 8 +- web/display/src/ranges.tsx | 25 +- web/display/vite.config.ts | 3 + web/shared/charts/TimeChart.tsx | 2 + 14 files changed, 921 insertions(+), 251 deletions(-) create mode 100644 web/display/src/pages/Host.tsx diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx index aa2d444..b5ca975 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -257,7 +257,7 @@ export function AgentDetail() {
控制端口
{a.probe_tcp_port}/tcp
数据端口
-
{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}
+
{a.probe_quic_ports.map((p) => `${p}/udp (QUIC)`).join(', ') || '—'}
版本
{a.agent_version ?? '—'}
展示页可见
diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index 00ae758..8a56305 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -17,8 +17,10 @@ import { OwnerPicker } from './OwnerPicker' */ function LinkMatrix() { const matrix = useQuery(() => api.get('/api/admin/links'), [], 8000) + const probeState = useQuery(() => api.get>('/api/admin/probes'), [], 8000) const { busy, run } = useAction() const items = matrix.data?.items ?? [] + const onlineByProbe = new Map((probeState.data?.items ?? []).map((probe) => [probe.id, probe.online])) const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()] const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()] const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId) @@ -58,12 +60,20 @@ function LinkMatrix() { {agents.map(([agentId]) => { const link = cell(probeId, agentId) if (!link) return + const probeOnline = onlineByProbe.get(probeId) const tone = link.connected ? 'ok' : link.linked ? 'warn' : '' - const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连' + const text = link.connected ? '已连接' : !link.linked ? '不连' : probeOnline === false ? 'Probe 离线' : '待连接' + const statusHint = link.connected + ? 'Probe 已和 Agent 建立测量会话' + : !link.linked + ? '这条线路已关闭' + : probeOnline === false + ? 'Probe 当前没有连接到面板;Probe 上线后才会尝试连接 Agent' + : '线路已启用,等待 Probe 与 Agent 建立测量会话' return ( - {link.override !== null && link.override !== undefined ? ( diff --git a/web/display/src/App.tsx b/web/display/src/App.tsx index 86a6cf4..b2d1895 100644 --- a/web/display/src/App.tsx +++ b/web/display/src/App.tsx @@ -5,6 +5,7 @@ import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react- import { SourceButton, ThemeToggle } from '@shared/ui' import { Compare } from './pages/Compare' import { Line } from './pages/Line' +import { Host } from './pages/Host' import { Overview } from './pages/Overview' import { useBackgroundTint } from './backgroundTint' import { useBackgroundParallax } from './parallax' @@ -51,6 +52,7 @@ export function App() { } /> } /> + } /> } /> } /> diff --git a/web/display/src/LineStrips.tsx b/web/display/src/LineStrips.tsx index ae0d0f0..7718597 100644 --- a/web/display/src/LineStrips.tsx +++ b/web/display/src/LineStrips.tsx @@ -19,52 +19,6 @@ const levelOf = (loss: number | null | undefined): Level => (loss === null || lo const clock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) -interface StripProps { - /** 左上:什么延迟(UDP 延迟 p50)。 */ - rttLabel: string - rtt: number | null | undefined - /** 右上:丢包还是重传。 */ - badLabel: string - bad: number | null | undefined - badHint?: string - /** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */ - t: number[] - badSeries: (number | null)[] - ready: boolean - /** 提示里这一格的「丢包」叫什么。 */ - tipBad: string - /** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */ - cells: number - stepText: string - hoursText: string - /** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */ - hover: number | null -} - -/** - * 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据) - * 尽量轻。 - */ -function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) { - return ( -
-
- - {rttLabel} {fmtMs(rtt)} - - - {badLabel} {fmtPct(bad)} - -
-
- {(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => ( - - ))} -
-
- ) -} - interface Props { lineId: string range: OverviewRange @@ -72,23 +26,23 @@ interface Props { m: Record } -/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */ +/** 一条线路的 QUIC/TCP 双行摘要,各自显示时延、质量点阵和丢包/重传率。 */ export function LineStrips({ lineId, range, m }: Props) { const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0] const series = useQuery( - () => api.get(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'udp,tcp', metrics: 'loss.total,rtt.p50,tcp.retrans,tcp.rtt.p50' }), + () => api.get(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'quic,tcp', metrics: 'loss.up,loss.down,loss.total,rtt.p50,rtt.p95,rtt.p99,tcp.retrans,tcp.rtt.p50' }), [lineId, info.value], 30_000, ) const s = series.data const t = s?.t ?? [] const col = (key: string): (number | null)[] => s?.values[key] ?? [] - // 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP + // 悬停的那一格:QUIC 上下行和 TCP 共用时间轴。 const [hover, setHover] = useState(null) const last = useRef(null) // 提示收回去的动画期间,内容还要留着 if (hover !== null) last.current = hover const shown = hover ?? last.current - // 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上 + // 整块线路区域都能触发:按鼠标横向位置对应到采样格,不用精确指到小格上。 const box = useRef(null) const onMove = (e: MouseEvent) => { const bars = box.current?.querySelector('.bars') @@ -98,19 +52,50 @@ export function LineStrips({ lineId, range, m }: Props) { } const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover } const at = shown !== null && t[shown] !== undefined ? shown : null + const lossUp = col('loss.up') + const lossDown = col('loss.down') + const retrans = col('tcp.retrans') + const up = m['loss.up'] + const down = m['loss.down'] + const upText = up === null || up === undefined ? '丢包 —' : `${fmtPct(up)} 丢包` + const downText = down === null || down === undefined ? '丢包 —' : `${fmtPct(down)} 丢包` + const retransValue = m['tcp.retrans'] + const retransTone = levelOf(retransValue) + const retransText = retransValue === null || retransValue === undefined ? '重传 —' : `${fmtPct(retransValue)} 重传` return (
setHover(null)}> - - +
+
+ QUIC{fmtMs(m['rtt.p50'])}悬停看 p95 / p99 +
+
+ 去程 +
+ {(s ? lossUp : Array.from({ length: common.cells }, () => null)).map((value, i) => ( + + ))} +
+ {upText} +
+
+ 回程 +
+ {(s ? lossDown : Array.from({ length: common.cells }, () => null)).map((value, i) => ( + + ))} +
+ {downText} +
+
+ TCP{fmtMs(m['tcp.rtt.p50'])} +
+ {(s ? retrans : Array.from({ length: common.cells }, () => null)).map((value, i) => ( + + ))} +
+ {retransText} +
+
{at !== null ? ( <> @@ -118,12 +103,15 @@ export function LineStrips({ lineId, range, m }: Props) { {clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
- UDP + QUIC Datagram + RTT p50 {fmtMs(col('rtt.p50')[at])} + RTT p95 {fmtMs(col('rtt.p95')[at])} + RTT p99 {fmtMs(col('rtt.p99')[at])} - 丢包 {fmtPct(col('loss.total')[at])} + 去程丢包 {fmtPct(col('loss.up')[at])} - 延迟 p50 {fmtMs(col('rtt.p50')[at])} + 回程丢包 {fmtPct(col('loss.down')[at])}
diff --git a/web/display/src/Minimap.tsx b/web/display/src/Minimap.tsx index 6c43aca..2f5314c 100644 --- a/web/display/src/Minimap.tsx +++ b/web/display/src/Minimap.tsx @@ -209,7 +209,8 @@ export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSel {ticks - .filter((x) => x.midnight) + // Outer edges already have date labels; only draw midnight markers inside the selected domain. + .filter((x) => x.midnight && x.frac > 0 && x.frac < 1) .map((x) => (
))} diff --git a/web/display/src/display.css b/web/display/src/display.css index ce40698..037d085 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -70,6 +70,523 @@ padding: 40px 24px 80px; } +/* 线路页是数据工作区:宽屏充分利用空间,图表和辅助文字保持清晰对比。 */ +.line-page { + --line-secondary: color-mix(in srgb, var(--app-ink) 58%, var(--app-ink-muted)); + width: min(100%, 1640px); + max-width: 1640px; + padding-top: 24px; + padding-bottom: 56px; + /* 正文背景上的全局柔光会让标题、节点状态和说明显得发虚;数据页文字保持锐利。 */ + text-shadow: none; +} + +.line-page .hero h1 { + font-size: 32px; +} + +.line-page .line-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 16px; +} + +.line-page .line-title-row h1 { + margin: 0; + font-size: clamp(24px, 2vw, 32px); +} + +.line-page .line-version-switch { + gap: 6px; +} + +.line-page .line-route-meta { + gap: 6px 12px; + margin-top: 6px; + font-size: 12px; +} + +.line-page .line-route-status { + color: var(--app-ink); + font-weight: 650; +} + +.line-page .line-range-picker { + margin-top: 8px; +} + +.line-page .faint, +.line-page .chart-hint, +.line-page .chart-section h2, +.line-page .stat .k { + color: var(--line-secondary); + font-weight: 500; +} + +.line-page .line-peer-name { + color: var(--line-secondary); + font-weight: 500; +} + +.line-page .line-section-label { + color: var(--app-ink); + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.line-page .line-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 16px; + padding: 8px 12px; + border-radius: var(--r-card); + background: color-mix(in srgb, var(--app-card) 78%, transparent); +} + +.line-page .line-toolbar-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.line-page .line-smoothing { + margin-left: 0; +} + +.line-page .line-header-surface, +.line-page .line-metric-surface { + background: var(--glass-bg); + backdrop-filter: var(--glass-filter); + box-shadow: inset 0 1px 0 var(--glass-top), var(--glass-drop); +} + +.line-page .line-header-surface { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px 20px 16px; + border-radius: var(--r-group-outer); +} + +.line-page .line-metric-surface { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + border-radius: var(--r-group-outer); +} + +.line-page .line-metric-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px 20px; +} + +.line-page .line-metric-heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 12px; +} + +.line-page .line-metric-heading h2 { + color: var(--app-ink); + font-size: 19px; + font-weight: 700; +} + +.line-page .line-metric-heading > span { + color: var(--line-secondary); + font-size: 12px; +} + +.line-page .range-toolbar { + align-items: center; + gap: 8px 14px; +} + +.line-page .range-toolbar-right { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px 14px; + margin-left: auto; +} + +.line-page .line-data-notes { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 14px; + color: var(--line-secondary); + font-size: 11px; + font-weight: 450; + line-height: 1.4; +} + +.line-page .crumbs, +.line-page .minimap .tick, +.line-page .minimap .readout, +.line-page .chart-legend, +.line-page .stat .v small { + color: var(--line-secondary); +} + +.line-page .minimap .tick { + font-size: 12px; + font-weight: 500; +} + +.line-page .chart-section h2, +.line-page .chart-card h3 { + font-weight: 700; +} + +.line-page .chart-card { + min-width: 0; + gap: 2px; + padding: 12px 14px; + background: color-mix(in srgb, var(--glass-line) 4%, transparent); + box-shadow: none; + backdrop-filter: none; +} + +.line-page .chart-card h3 { + font-size: 14px; + color: var(--app-ink); +} + +.line-page .quic-chart-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.line-page .line-quic-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.line-page .line-combo-card { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + gap: 5px; + min-height: 84px; + padding: 10px 14px; + border-radius: var(--r-control); + background: color-mix(in srgb, var(--glass-line) 4%, transparent); +} + +.line-page .line-combo-card h3 { + color: var(--line-secondary); + font-size: 12px; + font-weight: 600; +} + +.line-page .line-combo-primary { + display: flex; + align-items: baseline; + gap: 8px; + color: var(--line-secondary); + font-size: 13px; +} + +.line-page .line-combo-primary strong { + color: var(--app-ink); + font-size: 24px; + font-weight: 700; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.line-page .combo-tone-ok { + color: var(--app-ok) !important; +} + +.line-page .combo-tone-warn { + color: var(--app-warn) !important; +} + +.line-page .combo-tone-bad { + color: var(--app-bad) !important; +} + +.line-page .line-combo-secondary, +.line-page .line-jitter-pair { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 10px; + color: var(--line-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.line-page .line-combo-secondary b, +.line-page .line-jitter-pair b { + color: var(--app-ink); + font-weight: 650; +} + +.line-page .combo-divider { + opacity: 0.55; +} + +.line-page .line-combo-badge { + align-self: flex-start; + padding: 2px 7px; + border-radius: var(--r-pill); + background: var(--app-card-inset); + color: var(--line-secondary); + font-size: 10px; + line-height: 1.4; + font-variant-numeric: tabular-nums; +} + +.line-page .line-jitter-pair { + gap: 8px 20px; + font-size: 16px; +} + +.line-page .line-session-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 18px; + padding: 10px 4px 2px; + border-top: 1px solid var(--glass-head-line); + background: transparent; + color: var(--line-secondary); + font-size: 12px; +} + +.line-page .line-session-title { + color: var(--app-ink); + font-weight: 700; + white-space: nowrap; +} + +.line-page .line-session-items { + display: flex; + flex: 1; + flex-wrap: wrap; + gap: 5px 18px; +} + +.line-page .line-session-items b { + color: var(--app-ink); + font-variant-numeric: tabular-nums; +} + +.line-page .line-session-bar small { + flex-basis: 100%; + color: var(--line-secondary); + font-size: 11px; +} + +.line-page .tiles { + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 14px; +} + +.line-page .tiles > .stat { + box-shadow: 0 8px 24px -20px var(--app-shadow); +} + +.line-page .line-metric-subhead { + grid-column: 1 / -1; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid var(--app-outline-soft); + color: var(--app-ink); + font-size: 14px; + font-weight: 700; +} + +.line-page .line-wide-note { + grid-column: 1 / -1; +} + +.line-page .stack { + gap: 10px; +} + +.line-page .chart-section { + gap: 6px; +} + +.line-page .chart-section h2 { + margin-top: 4px; +} + +.line-page .minimap { + margin-top: 6px; +} + +.line-page .minimap .track { + height: 42px; + margin-bottom: 22px; + background: repeating-linear-gradient( + 135deg, + color-mix(in srgb, var(--glass-bg) 94%, var(--glass-shade) 6%) 0 8px, + color-mix(in srgb, var(--glass-bg) 86%, var(--glass-shade) 14%) 8px 11px + ); +} + +.line-page .minimap .ticks { + height: 20px; +} + +.line-page .minimap .tick { + height: 20px; + padding-top: 8px; + font-size: 11px; + letter-spacing: 0.01em; +} + +.line-page .minimap .tick.date { + font-size: 11px; + font-weight: 650; +} + +.line-page .minimap .readout { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 3px; + font-size: 11px; + line-height: 1.5; + letter-spacing: 0.01em; +} + +.line-page .minimap .readout > span { + margin-left: 2px; +} + +.line-page .minimap .sel { + border-block: 1px solid var(--pick); + border-inline: 0; + border-radius: 8px; + background: color-mix(in srgb, var(--pick) 12%, transparent); +} + +.line-page .minimap .sel .h { + top: 8px; + bottom: 8px; + width: 4px; + border-radius: 2px; +} + +.line-page .minimap .sel .h::before { + position: absolute; + inset: -8px -4px; + content: ''; +} + +.line-page .minimap .sel .h.l { + left: -2px; +} + +.line-page .minimap .sel .h.r { + right: -2px; +} + +.line-page .minimap svg rect.has { + fill: var(--app-card); +} + +.line-page .range-picker > .row:last-child { + margin-top: 4px !important; +} + +@media (max-width: 760px) { + .line-page { + padding: 20px 14px 48px; + } + + .line-page .line-header-surface { + padding: 14px; + border-radius: var(--r-card); + } + + .line-page .line-metric-surface { + gap: 12px; + padding: 12px; + border-radius: var(--r-card); + } + + .line-page .line-metric-header { + align-items: flex-start; + } + + .line-page .range-toolbar > .group { + max-width: 100%; + min-width: 0; + flex-wrap: nowrap; + overflow-x: auto; + } + + .line-page .range-toolbar > .group > button, + .line-page .range-toolbar-right .group > button { + flex: none; + white-space: nowrap; + } + + .line-page .range-toolbar-right { + width: 100%; + flex-wrap: nowrap; + justify-content: flex-start; + overflow-x: auto; + } + + .line-page .minimap .tick { + width: max-content; + min-width: 0; + padding-right: 1px; + padding-left: 1px; + font-size: 9px; + white-space: nowrap; + } + + .line-page .minimap .tick:not(.date):not(:nth-child(6n + 1)) { + display: none; + } + + .line-page .hero h1 { + font-size: 28px; + } + + .line-page .line-title-row { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .line-page .line-smoothing { + margin-left: 0; + } + + .line-page .line-quic-summary { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 900px) { + .line-page .quic-chart-grid { + grid-template-columns: minmax(0, 1fr); + } +} + .hero h1 { font-size: 40px; font-weight: 800; @@ -716,7 +1233,7 @@ } -/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */ +/* ── 线路详情的图表:按 QUIC、TCP、评分分组,宽图占满一行 ── */ .chart-section { display: flex; flex-direction: column; @@ -799,7 +1316,7 @@ color: var(--app-ink-faint); } -/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */ +/* ── 总览卡片里的 QUIC Datagram:按方向显示丢包小格 ── */ .line-name { font-weight: 600; font-size: 13px; @@ -809,47 +1326,123 @@ font-size: 11px; } -.udp-strip { - position: relative; +.host-link { + color: inherit; + text-decoration: none; + cursor: pointer; + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.host-link:hover, +.host-link:focus-visible { + color: inherit; + transform: translateY(-2px); + box-shadow: 0 12px 32px -14px color-mix(in srgb, var(--glass-shade) 75%, transparent); +} + +.host-link:focus-visible { + outline: none; +} + +.line-mini-stack { display: flex; flex-direction: column; gap: 4px; } -.udp-head { +.line-mini-quic-head { display: flex; - align-items: baseline; - justify-content: space-between; - font-size: 11px; + min-width: 0; + align-items: center; + gap: 8px; + line-height: 1.2; } -.udp-title { - color: var(--app-ink-faint); +.line-mini-quic-head > span, +.line-mini-direction-row > span:first-child, +.line-mini-tcp-row > span:first-child { + color: var(--app-ink-muted); + font-size: 10px; + font-weight: 600; } -.udp-title b { - margin-left: 2px; - font-size: 13px; - font-weight: 700; +.line-mini-quic-head > strong, +.line-mini-tcp-row > strong { color: var(--app-ink); + font-size: 14px; + font-weight: 700; + white-space: nowrap; } -.udp-title b.udp-rate.ok { +.line-mini-quic-head > small { + margin-left: auto; + color: var(--app-ink-faint); + font-size: 9px; + white-space: nowrap; +} + +.line-mini-direction-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 66px; + align-items: center; + gap: 5px; + min-width: 0; +} + +.line-mini-direction-row > span:first-child, +.line-mini-tcp-row > 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; + margin-top: 2px; + padding-top: 5px; + border-top: 1px solid var(--app-outline-soft); +} + +.line-mini-tcp-row > strong { + font-size: 12px; +} + +.line-mini-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 5px; + border-radius: var(--r-pill); + background: var(--app-card-inset); + color: var(--app-ink-muted); + font-size: 9px; + line-height: 1.4; + white-space: nowrap; +} + +.line-mini-badge.ok { + background: var(--app-ok-container); color: var(--app-ok); } -.udp-title b.udp-rate.warn { +.line-mini-badge.warn { + background: var(--app-warn-container); color: var(--app-warn); } -.udp-title b.udp-rate.bad { +.line-mini-badge.bad { + background: var(--app-bad-container); color: var(--app-bad); } .bars { display: flex; - gap: 2px; - height: 10px; + min-width: 0; + gap: 1.5px; + height: 8px; } .bar { @@ -875,7 +1468,7 @@ opacity: 1; } -/* UDP 和 TCP 两段之间留一点空 */ +/* QUIC 和 TCP 两段之间留一点空 */ .strips { display: flex; flex-direction: column; diff --git a/web/display/src/pages/Compare.tsx b/web/display/src/pages/Compare.tsx index f070d78..ef45f24 100644 --- a/web/display/src/pages/Compare.tsx +++ b/web/display/src/pages/Compare.tsx @@ -21,9 +21,11 @@ interface MetricInfo { const METRICS: MetricInfo[] = [ { id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' }, { id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' }, - { id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' }, - { id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' }, - { id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' }, + { id: 'loss.total', label: 'QUIC 丢包', unit: '%', better: 'lower' }, + { id: 'loss.up', label: 'QUIC 上行丢包', unit: '%', better: 'lower' }, + { id: 'loss.down', label: 'QUIC 下行丢包', unit: '%', better: 'lower' }, + { id: 'loss.run.up_max', label: 'QUIC 上行最长连续丢包', unit: '', better: 'lower' }, + { id: 'loss.run.down_max', label: 'QUIC 下行最长连续丢包', unit: '', better: 'lower' }, { id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' }, { id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' }, { id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' }, diff --git a/web/display/src/pages/Host.tsx b/web/display/src/pages/Host.tsx new file mode 100644 index 0000000..74a8d95 --- /dev/null +++ b/web/display/src/pages/Host.tsx @@ -0,0 +1,89 @@ +import { ChevronLeft } from 'lucide-react' +import { useMemo } from 'react' +import { Link, useParams, useSearchParams } from 'react-router-dom' +import { api } from '@shared/api/client' +import { TimeChart } from '@shared/charts/TimeChart' +import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts } from '../live' +import { useQuery } from '@shared/hooks' +import { Chip, Dot, Segmented, Spinner, Stat } from '@shared/ui' +import { fmtAgo } from '@shared/format' +import { OsIcon } from '@shared/osIcon' +import { Flag, IspIcon, useNodeInfos } from '@shared/nodes' +import { PageError } from '../lib' + +interface HostHistory { + t: number[] + values: Record +} + +const RANGES = [ + { value: '1h', label: '近 1 小时' }, + { value: '6h', label: '近 6 小时' }, + { value: '24h', label: '近 24 小时' }, + { value: '7d', label: '近 7 天' }, +] as const + +export function Host() { + const { id = '' } = useParams() + const [params, setParams] = useSearchParams() + const range = RANGES.find((item) => item.value === params.get('range'))?.value ?? '24h' + const { hosts, live } = useHosts() + const infos = useNodeInfos() + const node = hosts.get(id) + const info = infos.get(id) + const history = useQuery(() => api.get(`/api/public/hosts/${encodeURIComponent(id)}`, { range }), [id, range], 30_000) + const data = history.data + const usage = useMemo(() => data ? [ + { label: 'CPU', values: data.values.cpu ?? [], color: '--chart-1' }, + { label: '内存', values: data.values.mem ?? [], color: '--chart-2' }, + { label: '磁盘', values: data.values.disk ?? [], color: '--chart-4' }, + ] : [], [data]) + const network = useMemo(() => data ? [ + { label: '上行', values: data.values.tx_bps ?? [], color: '--chart-2' }, + { label: '下行', values: data.values.rx_bps ?? [], color: '--chart-1' }, + ] : [], [data]) + const host = node?.host + + return ( +
+
总览
+
+

{node?.name ?? id}

+
+ {info?.country ? : null} + {info?.isp_key ? : null} + {info?.os_name ? {[info.os_name, info.arch].filter(Boolean).join(' / ')} : null} + {node ? <>{node.kind === 'agent' ? 'Agent' : 'Probe'}{node.online ? (node.stale ? '状态数据暂未更新' : '在线') : '离线'} : 节点信息加载中…} + {node?.last_seen_at && !node.online ? 最后在线 {fmtAgo(node.last_seen_at)} : null} + +
+
+ + + {host ?
+
+ + + + + + + + +
+
:

尚无主机资源快照;节点上线并上报状态后会显示实时数据。

} + +
+
+ 资源历史 + item)} value={range} onChange={(value) => setParams((prev) => { const next = new URLSearchParams(prev); next.set('range', value); return next }, { replace: true })} /> + {history.loading ? : null} +
+ {data ?
+

CPU、内存与磁盘

+

网络吞吐

+
: !history.loading ?

所选时间段暂无历史数据。

: null} +
+
+ ) +} diff --git a/web/display/src/pages/Line.tsx b/web/display/src/pages/Line.tsx index ea0271b..cb51dfc 100644 --- a/web/display/src/pages/Line.tsx +++ b/web/display/src/pages/Line.tsx @@ -1,5 +1,5 @@ import { ChevronLeft } from 'lucide-react' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions' import { Link, useParams, useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' @@ -7,7 +7,6 @@ import type { LineDto, TestDto } from '@shared/api/types' import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart' import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format' import { NodeTag, useNodeInfos } from '@shared/nodes' -import { fmtBytes, useHosts } from '../live' import { useQuery } from '@shared/hooks' import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui' import { PageError, ScoreChips } from '../lib' @@ -24,28 +23,25 @@ function tone(value: number | null | undefined, good: number, poor: number): 'ok return 'warn' } -interface HostSeries { - t: number[] - values: Record -} - /** 指标卡片:好坏的分界(越低越好)和显示格式。 */ -const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [ - { label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs }, - { label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs }, - { label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 }, - { label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 }, - { label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 }, - { label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' }, - { label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs }, - { label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs }, - { label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs }, - { label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs }, - { label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs }, - { label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 }, - { label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true }, - { label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true }, - { label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true }, +type MetricGroup = 'quic' | 'tcp-connect' | 'score' + +const METRIC_GROUPS: { value: MetricGroup; label: string }[] = [ + { value: 'quic', label: 'QUIC' }, + { value: 'tcp-connect', label: 'TCP' }, + { value: 'score', label: '评分' }, +] + +const QUIC_METRICS = 'rtt.p50,rtt.p95,rtt.p99,loss.total,loss.up,loss.down,loss.run.up_max,loss.run.down_max,jitter.up.p95,jitter.down.p95' + +const TILES: { group: Exclude; label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [ + { group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs }, + { group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs }, + { group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs }, + { group: 'tcp-connect', label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 }, + { group: 'tcp-connect', label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true }, + { group: 'tcp-connect', label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true }, + { group: 'tcp-connect', label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true }, ] interface ChartDef { @@ -55,26 +51,26 @@ interface ChartDef { zero?: boolean fill?: boolean series: { key: string; label: string; color: string; dash?: number[] }[] - group: 'udp' | 'tcp' | 'connect' | 'score' + group: MetricGroup /** 占满一行。 */ wide?: boolean } const GROUPS = [ - { id: 'udp', title: 'UDP(往返探测)' }, - { id: 'tcp', title: 'TCP' }, - { id: 'connect', title: '建连(TCP 握手、TLS 握手)' }, + { id: 'quic', title: 'QUIC Datagram' }, + { id: 'tcp-connect', title: 'TCP' }, { id: 'score', title: '评分' }, ] as const const CHARTS: ChartDef[] = [ - { id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] }, - { id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] }, - { id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] }, - { id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] }, - { id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] }, - { id: 'connect-time', title: '建连耗时(p95)', group: 'connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] }, - { id: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] }, + { id: 'rtt', title: '延迟', group: 'quic', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] }, + { id: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] }, + { id: 'loss', title: '丢包', group: 'quic', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] }, + { id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '上行', color: '--chart-2' }, { key: 'loss.run.down_max', label: '下行', color: '--chart-3' }] }, + { id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] }, + { id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] }, + { id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] }, + { id: 'connect-ok', title: '建连成功率', group: 'tcp-connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] }, { id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] }, ] @@ -100,58 +96,10 @@ function ewma(values: (number | null)[], alpha: number): (number | null)[] { }) } -/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */ -function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) { - const history = useQuery( - () => (agentId && range ? api.get(`/api/public/hosts/${agentId}`, { range }) : Promise.resolve(undefined)), - [agentId, range], - 30_000, - ) - const { hosts } = useHosts() - const host = agentId ? hosts.get(agentId)?.host : undefined - const data = history.data - const charts = useMemo( - () => - data - ? { - usage: [ - { label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' }, - { label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' }, - { label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' }, - ], - speed: [ - { label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' }, - { label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' }, - ], - } - : undefined, - [data], - ) - if (!host && !data) return null - return ( -
-
主机状态
- {host ? ( -
- - - - -
- ) : null} - {data && charts ? ( -
- - -
- ) : null} -
- ) -} - export function Line() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() + const [metricGroup, setMetricGroup] = useState('quic') const line = useQuery(() => api.get(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000) const infos = useNodeInfos() @@ -162,7 +110,13 @@ export function Line() { const spec = readSpec(params) const period = usePeriod(id, ready, sinceMs, nowMs, spec) - const series = usePeriodSeries(id, ready, period) + const series = usePeriodSeries( + id, + ready, + period, + metricGroup === 'score' ? 'scores' : metricGroup === 'tcp-connect' ? 'tcp,connect' : metricGroup, + metricGroup === 'quic' ? QUIC_METRICS : undefined, + ) const update = (fn: (q: URLSearchParams) => void) => { const q = new URLSearchParams(params) @@ -198,7 +152,6 @@ export function Line() { writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at }) }) - const agentId = line.data?.agent_id const lastTestId = line.data?.last_test?.id const test = useQuery(() => (lastTestId ? api.get(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId]) @@ -211,7 +164,7 @@ export function Line() { const charts = useMemo( () => s - ? GROUPS.map((group) => { + ? GROUPS.filter((group) => group.id === metricGroup).map((group) => { const cards = CHARTS.filter((def) => def.group === group.id).map((def) => { const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] })) // 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面 @@ -223,20 +176,19 @@ export function Line() { ] : raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill })) if (!list.some((x) => x.values.some((v) => v !== null))) return null - return { def, node: } + return { def, node: } }) return { group, cards: cards.filter((c): c is NonNullable => c !== null) } }).filter((g) => g.cards.length > 0) : [], - [s, gaps, smooth.alpha], + [s, gaps, smooth.alpha, metricGroup], ) const loadingNow = !ready || series.busy const chartSections = charts.map(({ group, cards }) => (
-

{group.title}

-
+
{cards.map(({ def, node }) => ( -
+

{def.title}

{node} @@ -250,7 +202,7 @@ export function Line() { const peak = result?.metrics?.['tcp.bulk.peak.max'] return ( -
+
@@ -258,12 +210,13 @@ export function Line() {
-
-

- {line.data?.name ?? '线路'} - {line.data ? → {line.data.probe_name} : null} -

-
+
+
+

+ {line.data?.name ?? '线路'} + {line.data ? → {line.data.probe_name} : null} +

+
{IP_VERSIONS.map((v) => { const query = new URLSearchParams(params) query.set('ip_version', v.value) @@ -274,11 +227,12 @@ export function Line() { ) })} {line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? 此 Agent 未配置 IPv6 地址 : null} +
{line.data ? ( -
+
- {line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,测量通道未建立(Probe 没能和它建立测量会话;机器可能是通的,ping 通不代表测量用的 TCP 和 UDP 通道可用)') : 'Agent 离线'} + {line.data.online ? (line.data.probe_connected ? '在线' : '测量未连接') : '离线'} Agent @@ -293,7 +247,7 @@ export function Line() { {series.loading && !s ? : null}
) : null} -
+
+ 平滑 + + options={EWMA.map((x) => ({ value: x.value, label: x.label }))} + value={smooth.value} + onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))} + /> +
+ } > -
-
- - 评分 - +
+
+
+
+

{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}

+ {metricGroup === 'quic' ? 往返时延 · 双向丢包 · 抖动 : null} +
+ options={METRIC_GROUPS} value={metricGroup} onChange={setMetricGroup} /> +
+ {metricGroup === 'quic' ? <> +
+
+

QUIC RTT

+
p50{fmtMs(m['rtt.p50'])}
+
p95 {fmtMs(m['rtt.p95'])}|p99 {fmtMs(m['rtt.p99'])}
+
+
+

QUIC Datagram 丢包

+
总丢包{fmtPct(m['loss.total'])}
+
↑ {fmtPct(m['loss.up'])}/↓ {fmtPct(m['loss.down'])}
+ 最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包 +
+
+

QUIC 抖动 p95

+
↑ {fmtMs(m['jitter.up.p95'])}↓ {fmtMs(m['jitter.down.p95'])}
+
- {TILES.filter((t) => !t.optional || m[t.key] != null).map((t) => ( +
+ QUIC 会话与传输 + {line.data?.quic_health ? (() => { + const h = line.data.quic_health! + return <> +
+ 握手 p95 {fmtMs(h.handshake_p95_ms)} + 成功率 {fmtPct(h.handshake_success_rate)} + 超时 {fmtPct(h.handshake_timeout_rate)} + 失败 {fmtPct(h.handshake_failure_rate)} + 传输疑似丢包 {fmtPct(h.transport_packet_loss_rate)} + 路径 MTU {fmtNum(h.path_mtu)} B +
+ + })() : Probe 尚未上报会话数据} +
+ : null} + {metricGroup !== 'quic' ?
+ {metricGroup === 'score' ?
评分
: null} + {TILES.filter((t) => t.group === metricGroup && (!t.optional || m[t.key] != null)).map((t) => ( ))} -
-

- 样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])} -

- {s.summary.coverage < 0.95 ?

这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。

: null} - -
- - EWMA 平滑 - - - options={EWMA.map((x) => ({ value: x.value, label: x.label }))} - value={smooth.value} - onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))} - /> +
: null} +
+ 样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])} + {s.summary.coverage < 0.95 ? 数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段 : null}
{chartSections} - - +
{test.data && result ? (
@@ -392,22 +383,6 @@ export function Line() {
) : null} - {s.events.length > 0 ? ( -
-
事件
-
- {s.events.slice(0, 20).map((e) => ( -
- - {fmtTime(e.at)} - - {e.kind} - {e.detail ? {e.detail} : null} -
- ))} -
-
- ) : null}
) : null}
diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index e23371f..e29457d 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -12,7 +12,7 @@ import { PageError } from '../lib' import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions' import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips' -const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',') +const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',') type SortKey = 'name' | 'rtt' | 'loss' @@ -53,7 +53,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i const stale = !node.online || node.stale const info = infos.get(node.id) return ( -
+
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */} {info?.country ? : } @@ -133,11 +133,11 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
这台 {KIND_NAME[node.kind]} 没有上报主机状态
)}
-
+ ) } -/** 一条线路(Probe → Agent):近一段时间的 UDP 丢包和延迟走势、TCP 的 RTT 和重传。 */ +/** 一条线路(Probe → Agent):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map }) { const probeInfo = infos.get(l.probe_id) const agentInfo = infos.get(l.agent_id) @@ -153,7 +153,7 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang {l.name} {!l.probe_connected && l.online ? ( - + 测量通道未建立 ) : ( diff --git a/web/display/src/periods.ts b/web/display/src/periods.ts index 1090d72..eda46ae 100644 --- a/web/display/src/periods.ts +++ b/web/display/src/periods.ts @@ -98,7 +98,7 @@ export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(), to: new Date(domainEnd).toISOString(), step: wide ? '2m' : '1m', - preset: 'udp', + preset: 'quic', metrics: 'rtt.p50,loss.total', }) : Promise.resolve(undefined), @@ -171,10 +171,10 @@ export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null } /** 这一段的序列;今天的「最近一段」会定时刷新。 */ -export function usePeriodSeries(lineId: string, ready: boolean, period: Period) { +export function usePeriodSeries(lineId: string, ready: boolean, period: Period, preset = 'quic,tcp,connect,scores', metrics?: string) { return useQuery( - () => (ready ? api.get(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,connect,scores' }) : Promise.resolve(undefined)), - [lineId, JSON.stringify(period.query), ready], + () => (ready ? api.get(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, ...(metrics ? { metrics } : { preset }) }) : Promise.resolve(undefined)), + [lineId, JSON.stringify(period.query), ready, preset, metrics], period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined, ) } diff --git a/web/display/src/ranges.tsx b/web/display/src/ranges.tsx index 2821593..69db9fd 100644 --- a/web/display/src/ranges.tsx +++ b/web/display/src/ranges.tsx @@ -107,11 +107,13 @@ interface Props { nowMs: number /** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */ spanMs: number | null + /** 跟时长快捷键和刻度选项放在同一行的辅助控件。 */ + toolbar?: ReactNode /** 放在日期和档位下面(缩略图)。 */ children?: ReactNode } -export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) { +export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, toolbar, children }: Props) { const picker = useRef(null) const today = dayBounds(nowMs).start const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start @@ -157,7 +159,7 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, ) : null}
{children} -
+
{ const ok = available.includes(w.value) @@ -166,14 +168,17 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, value={active} onChange={onWindow} /> - - options={[ - { value: '24', label: '24 小时刻度', title: '缩略图显示一天' }, - { value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' }, - ]} - value={String(scale) as '24' | '48'} - onChange={(v) => onScale(v === '48' ? 48 : 24)} - /> +
+ {toolbar} + + options={[ + { value: '24', label: '24 小时刻度', title: '缩略图显示一天' }, + { value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' }, + ]} + value={String(scale) as '24' | '48'} + onChange={(v) => onScale(v === '48' ? 48 : 24)} + /> +
) diff --git a/web/display/vite.config.ts b/web/display/vite.config.ts index 5ee65d5..3e22b53 100644 --- a/web/display/vite.config.ts +++ b/web/display/vite.config.ts @@ -16,6 +16,9 @@ export default defineConfig(({ mode }) => { server: { port: 5173, strictPort: true, + // WSL cannot reliably receive inotify events for source files on /mnt/c. + // Poll only in WSL so Windows edits trigger HMR without slowing native hosts. + watch: process.env.WSL_DISTRO_NAME ? { usePolling: true, interval: 500 } : undefined, fs: { allow: ['..'] }, proxy: { // 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它 diff --git a/web/shared/charts/TimeChart.tsx b/web/shared/charts/TimeChart.tsx index e761fc8..0779646 100644 --- a/web/shared/charts/TimeChart.tsx +++ b/web/shared/charts/TimeChart.tsx @@ -160,6 +160,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap (u) => { const { ctx } = u ctx.save() + // Coverage gaps are context only; keep the warm mask subtle so spikes stay prominent. + ctx.globalAlpha = 0.16 ctx.fillStyle = shade for (const g of latest.current.gaps) { const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))