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.
This commit is contained in:
justme committed 2026-10-06 16:59:17 +08:00
1 parent a9538eae4a
commit a70b89b95c
3 files changed
+510 -33

No files matched your search

+35 -26
View File
@@ -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 (
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
<span className="muted">→ {l.probe_name}</span>
<span className="grow" />
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
<span className="num">{fmtMs(m['rtt.p50'])}</span>
<Chip tone={tone(m['loss.total'], 0.5, 3)}>{fmtPct(m['loss.total'])}</Chip>
<ScoreChips metrics={m} compact />
<div className="line-top">
<span className="line-name">→ {l.probe_name}</span>
<span className="faint">近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
</div>
<LineStrips lineId={l.id} range={range} m={m} />
</Link>
)
})}
@@ -113,7 +108,20 @@ export function Overview() {
const [probe, setProbe] = useState<string>('')
const [sort, setSort] = useState<SortKey>('name')
const { hosts, live } = useHosts()
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/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<OverviewDto>('/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 (
<main className="page">
<section className="hero">
<h1>MyMonitor</h1>
<h1>总览</h1>
<p className="sub">
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
@@ -154,11 +162,12 @@ export function Overview() {
<div className="toolbar">
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
<span className="right row">
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
<Segmented
options={[
{ value: 'name' as const, label: '名称' },
{ value: 'rtt' as const, label: '延迟' },
{ value: 'score' as const, label: '评分' },
{ value: 'loss' as const, label: '丢包' },
]}
value={sort}
onChange={setSort}
@@ -178,7 +187,7 @@ export function Overview() {
) : (
<div className="cards">
{cards.map((c) => (
<Card key={c.id} agent={c} host={hosts.get(c.id)} />
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} />
))}
</div>
)