From a70b89b95c9dae964990b8dfe8cd4f2ac788bbb6 Mon Sep 17 00:00:00 2001 From: JustMe Date: Tue, 6 Oct 2026 16:59:17 +0800 Subject: [PATCH] feat(web): show UDP and TCP strips on overview cards Add 3/6/12 hour ranges with status strips, latency sparklines and a side popover for the hovered interval. --- web/display/src/LineStrips.tsx | 187 ++++++++++++++++++ web/display/src/display.css | 295 ++++++++++++++++++++++++++++- web/display/src/pages/Overview.tsx | 61 +++--- 3 files changed, 510 insertions(+), 33 deletions(-) create mode 100644 web/display/src/LineStrips.tsx diff --git a/web/display/src/LineStrips.tsx b/web/display/src/LineStrips.tsx new file mode 100644 index 0000000..1bfb790 --- /dev/null +++ b/web/display/src/LineStrips.tsx @@ -0,0 +1,187 @@ +import { useMemo, useRef, useState, type MouseEvent } from 'react' +import { api } from '@shared/api/client' +import type { SeriesDto } from '@shared/api/types' +import { fmtMs, fmtPct } from '@shared/format' +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 }, +] as const + +export type OverviewRange = (typeof OVERVIEW_RANGES)[number]['value'] + +type Level = 'ok' | 'warn' | 'bad' | 'none' + +const levelOf = (loss: number | null | undefined): Level => (loss === null || loss === undefined ? 'none' : loss <= 0.5 ? 'ok' : loss < 3 ? 'warn' : 'bad') + +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)[] + rttSeries: (number | null)[] + ready: boolean + loading: boolean + /** 提示里这一格的「丢包」叫什么。 */ + tipBad: string + /** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */ + cells: number + stepText: string + hoursText: string + /** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */ + hover: number | null + onHover: (i: number | null) => void +} + +/** + * 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据) + * 加一条延迟的小走势(只标纵轴最大值),尽量轻。 + */ +function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, rttSeries, ready, loading, cells, stepText, hoursText, hover, onHover }: StripProps) { + const count = ready ? badSeries.length : 0 + const onSparkMove = (e: MouseEvent) => { + if (count < 2) return + const rect = e.currentTarget.getBoundingClientRect() + onHover(Math.min(count - 1, Math.max(0, Math.round(((e.clientX - rect.left) / rect.width) * (count - 1))))) + } + // 纵轴贴着数据走:从最小值到最大值(上下各留一点),而不是从 0 开始——延迟本来就在一个窄范围里波动,从 0 画会成一条平线 + const spark = useMemo(() => { + const points = rttSeries.map((v, i) => ({ v, i })).filter((p): p is { v: number; i: number } => p.v !== null && p.v !== undefined) + if (points.length < 2) return null + const lo = Math.min(...points.map((p) => p.v)) + const hi = Math.max(...points.map((p) => p.v)) + const mean = points.reduce((sum, p) => sum + p.v, 0) / points.length + const pad = Math.max((hi - lo) * 0.15, mean * 0.03, 1) + const top = hi + pad + const bottom = Math.max(0, lo - pad) + const n = rttSeries.length - 1 + const W = 300 + const H = 30 + const x = (i: number) => (i / n) * W + const y = (v: number) => H - ((v - bottom) / (top - bottom)) * H + const line = points.map((p, k) => `${k === 0 ? 'M' : 'L'}${x(p.i).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') + const area = `${line} L${x(points[points.length - 1].i).toFixed(1)},${H} L${x(points[0].i).toFixed(1)},${H} Z` + const fmt = (v: number) => (v >= 100 ? String(Math.round(v)) : v.toFixed(1)) + const dots = rttSeries.map((v) => (v === null || v === undefined ? null : y(v) / H)) + return { line, area, hi: fmt(hi), lo: fmt(lo), W, H, dots } + }, [rttSeries]) + + return ( +
+
+ + {rttLabel} {fmtMs(rtt)} + + + {badLabel} {fmtPct(bad)} + +
+
+ {(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => ( + ready && onHover(i)} /> + ))} +
+
+ {spark ? ( + <> + + {hover !== null && spark.dots[hover] !== null && spark.dots[hover] !== undefined ? ( + <> + + + + ) : null} + {spark.hi} + {spark.lo} + + ) : ( + {loading ? '加载中…' : '没有数据'} + )} +
+
+ ) +} + +interface Props { + lineId: string + range: OverviewRange + /** 近 3 小时的汇总(左上、右上的数字)。 */ + m: Record +} + +/** 一条线路近一段时间的 UDP 和 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' }), + [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 + const [hover, setHover] = useState(null) + const last = useRef(null) // 提示收回去的动画期间,内容还要留着 + if (hover !== null) last.current = hover + const shown = hover ?? last.current + const common = { t, ready: Boolean(s), loading: series.loading, cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover, onHover: setHover } + const at = shown !== null && t[shown] !== undefined ? shown : null + return ( +
setHover(null)}> + + +
+ {at !== null ? ( + <> + + {clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)} + +
+ UDP + + 丢包 {fmtPct(col('loss.total')[at])} + + + 延迟 p50 {fmtMs(col('rtt.p50')[at])} + +
+
+ TCP + + 重传 {fmtPct(col('tcp.retrans')[at])} + + + RTT p50 {fmtMs(col('tcp.rtt.p50')[at])} + +
+ + ) : null} +
+
+ ) +} diff --git a/web/display/src/display.css b/web/display/src/display.css index d5ece56..47670c8 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -239,6 +239,7 @@ } .agent-card { + position: relative; display: flex; flex-direction: column; gap: 12px; @@ -276,9 +277,9 @@ .line-link { display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; + flex-direction: column; + align-items: stretch; + gap: 6px; min-width: 0; padding: 6px 10px; border-radius: var(--r-control); @@ -412,10 +413,6 @@ background: var(--app-ok); } -.line-link > * { - white-space: nowrap; -} - .range-picker { display: flex; flex-direction: column; @@ -757,3 +754,287 @@ .minimap .sel .h.will.cut { background: var(--app-ink-muted); } + +/* 顶栏里的连接状态:紧凑的一条,放在导航和主题切换之间 */ +.topbar .src-status { + width: auto; + max-width: 220px; + border-radius: var(--r-pill, 999px); + padding: 6px 12px; +} + +@media (max-width: 640px) { + .topbar .src-status .st { + display: none; + } + + .topbar .src-status { + max-width: 120px; + } +} + + +.line-top { + display: flex; + align-items: center; + gap: 8px; +} + +.lines-caption { + padding: 0 4px 2px; + font-size: 12px; + color: var(--app-ink-faint); +} + +/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */ +.line-name { + font-weight: 600; + font-size: 13px; +} + +.line-top .faint { + font-size: 11px; +} + +.udp-strip { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; +} + +.udp-head { + display: flex; + align-items: baseline; + justify-content: space-between; + font-size: 11px; +} + +.udp-title { + color: var(--app-ink-faint); +} + +.udp-title b { + margin-left: 2px; + font-size: 13px; + font-weight: 700; + color: var(--app-ink); +} + +.udp-title b.udp-rate.ok { + color: var(--app-ok); +} + +.udp-title b.udp-rate.warn { + color: var(--app-warn); +} + +.udp-title b.udp-rate.bad { + color: var(--app-bad); +} + +.bars { + display: flex; + gap: 2px; + height: 10px; +} + +.bar { + flex: 1; + min-width: 0; + border-radius: 2px; + background: var(--app-idle); + opacity: 0.35; +} + +.bar.ok { + background: var(--app-ok); + opacity: 0.9; +} + +.bar.warn { + background: var(--app-warn); + opacity: 1; +} + +.bar.bad { + background: var(--app-bad); + opacity: 1; +} + +.spark { + position: relative; + height: 30px; +} + +.spark svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.spark .area { + fill: color-mix(in srgb, var(--app-accent) 12%, transparent); +} + +.spark .line { + fill: none; + stroke: var(--app-accent); + stroke-width: 1.2; +} + +.spark .ymax, +.spark .ymin { + position: absolute; + left: 2px; + font-size: 9px; + line-height: 1; + color: var(--app-ink-faint); +} + +.spark .ymax { + top: 0; +} + +.spark .ymin { + bottom: 0; +} + +.spark .empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 11px; +} + +/* UDP 和 TCP 两段之间留一点空 */ +.strips { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* 悬停的那一格:小格描边、走势图上一条竖线和一个点,再弹一个小提示 */ +.bar.on { + outline: 2px solid var(--app-ink); + outline-offset: 1px; +} + +.spark .guide { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--app-ink-faint); + pointer-events: none; +} + +.spark .dot { + position: absolute; + width: 7px; + height: 7px; + margin: -3.5px 0 0 -3.5px; + border-radius: 50%; + background: var(--app-accent); + box-shadow: 0 0 0 2px var(--app-card); + pointer-events: none; +} + +.strips { + position: relative; +} + +/* 提示弹在卡片右侧:平时收在卡片边上(透明、偏左),悬停时滑出来,移开时缩回去 */ +.pop { + position: absolute; + top: -4px; + left: calc(100% + 40px); + z-index: 20; + display: flex; + flex-direction: column; + gap: 8px; + width: 168px; + padding: 10px 12px; + border-radius: 12px; + background: var(--app-ink); + color: var(--app-card); + font-size: 12px; + line-height: 1.4; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translateX(-14px) scale(0.96); + transform-origin: left center; + transition: + opacity 0.16s ease, + transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); + box-shadow: 0 6px 20px color-mix(in srgb, var(--app-shadow), transparent 68%); +} + +.pop.open { + opacity: 1; + transform: none; +} + +/* 指向卡片的小三角 */ +.pop::before { + content: ''; + position: absolute; + top: 18px; + left: -5px; + width: 10px; + height: 10px; + background: var(--app-ink); + transform: rotate(45deg); + border-radius: 2px; +} + +.pop-time { + font-size: 12px; + font-weight: 700; + opacity: 0.8; +} + +.pop-group { + display: grid; + grid-template-columns: 1fr; + gap: 1px; +} + +.pop-name { + font-size: 10px; + letter-spacing: 0.08em; + opacity: 0.6; +} + +.pop em { + font-style: normal; + font-weight: 700; + margin-left: 4px; +} + +.pop em.warn { + color: var(--app-warn-container); +} + +.pop em.bad { + color: var(--app-bad-container); +} + +/* 右边放不下(窄屏、卡片贴着右边)时,提示改成盖在卡片上方 */ +@media (max-width: 900px) { + .pop { + left: auto; + right: 0; + top: -8px; + transform: translateY(8px) scale(0.96); + transform-origin: right bottom; + } + + .pop::before { + display: none; + } +} diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index ddcd97e..9a74a17 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -1,16 +1,17 @@ import { useMemo, useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { OverviewDto, OverviewLineDto } from '@shared/api/types' -import { fmtAgo, fmtMs, fmtPct } from '@shared/format' +import { fmtAgo } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui' import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live' -import { PageError, ScoreChips } from '../lib' +import { PageError } from '../lib' +import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips' -const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',') +const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',') -type SortKey = 'name' | 'rtt' | 'score' +type SortKey = 'name' | 'rtt' | 'loss' interface AgentCard { id: string @@ -20,12 +21,6 @@ interface AgentCard { lines: OverviewLineDto[] } -function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' { - if (value === null || value === undefined) return '' - if (value <= good) return 'ok' - if (value >= poor) return 'bad' - return 'warn' -} function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) { const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok' @@ -41,7 +36,7 @@ function Meter({ label, value, detail }: { label: string; value: number | null; ) } -function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) { +function Card({ agent, host, range }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange }) { const h = host?.host ?? null const stale = !agent.online || !host || host.stale return ( @@ -65,12 +60,12 @@ function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) const m = l.summary.metrics return ( - → {l.probe_name} - - {!l.probe_connected && l.online ? Probe 未连接 : null} - {fmtMs(m['rtt.p50'])} - {fmtPct(m['loss.total'])} - +
+ → {l.probe_name} + 近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range} + {!l.probe_connected && l.online ? Probe 未连接 : null} +
+ ) })} @@ -113,7 +108,20 @@ export function Overview() { const [probe, setProbe] = useState('') const [sort, setSort] = useState('name') const { hosts, live } = useHosts() - const { data, error, loading } = useQuery(() => api.get('/api/public/overview', { range: '15m', metrics: METRICS }), [], 10_000) + // 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h) + const [params, setParams] = useSearchParams() + const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h' + const setRange = (v: OverviewRange) => + setParams( + (prev) => { + const next = new URLSearchParams(prev) + if (v === '3h') next.delete('range') + else next.set('range', v) + return next + }, + { replace: true }, + ) + const { data, error, loading } = useQuery(() => api.get('/api/public/overview', { range, metrics: METRICS }), [range], 10_000) const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data]) const cards = useMemo(() => { @@ -131,13 +139,13 @@ export function Overview() { return [...byAgent.values()].sort((a, b) => { if (a.online !== b.online) return a.online ? -1 : 1 if (sort === 'name') return a.name.localeCompare(b.name) - const key = sort === 'rtt' ? 'rtt.p50' : 'score.base' - const x = best(a, key, sort === 'rtt' ? Math.min : Math.max) - const y = best(b, key, sort === 'rtt' ? Math.min : Math.max) + const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total' + const x = best(a, key, Math.min) + const y = best(b, key, Math.min) if (x === null && y === null) return a.name.localeCompare(b.name) if (x === null) return 1 if (y === null) return -1 - return sort === 'rtt' ? x - y : y - x + return x - y }) }, [data, probe, sort]) @@ -146,7 +154,7 @@ export function Overview() { return (
-

MyMonitor

+

总览

{data ? `${online} / ${cards.length} 台在线` : '加载中…'} @@ -154,11 +162,12 @@ export function Overview() {

{probes.length > 1 ? ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null} + options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} /> {cards.map((c) => ( - + ))}
)