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:
1 parent
a9538eae4a
commit
a70b89b95c
3 files changed
+510
-33
No files matched your search
@@ -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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div className="udp-strip">
|
||||
<div className="udp-head">
|
||||
<span className="udp-title">
|
||||
{rttLabel} <b className="num">{fmtMs(rtt)}</b>
|
||||
</span>
|
||||
<span className="udp-title" title={badHint}>
|
||||
{badLabel} <b className={`udp-rate num ${levelOf(bad)}`}>{fmtPct(bad)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${hoursText}每 ${stepText}的${badLabel}情况`}>
|
||||
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => (
|
||||
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} onMouseEnter={() => ready && onHover(i)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="spark" onMouseMove={onSparkMove}>
|
||||
{spark ? (
|
||||
<>
|
||||
<svg viewBox={`0 0 ${spark.W} ${spark.H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||
<path className="area" d={spark.area} />
|
||||
<path className="line" d={spark.line} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
{hover !== null && spark.dots[hover] !== null && spark.dots[hover] !== undefined ? (
|
||||
<>
|
||||
<i className="guide" style={{ left: `${(hover / (count - 1)) * 100}%` }} />
|
||||
<i className="dot" style={{ left: `${(hover / (count - 1)) * 100}%`, top: `${(spark.dots[hover] as number) * 100}%` }} />
|
||||
</>
|
||||
) : null}
|
||||
<span className="ymax num">{spark.hi}</span>
|
||||
<span className="ymin num">{spark.lo}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="faint empty">{loading ? '加载中…' : '没有数据'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lineId: string
|
||||
range: OverviewRange
|
||||
/** 近 3 小时的汇总(左上、右上的数字)。 */
|
||||
m: Record<string, number | null | undefined>
|
||||
}
|
||||
|
||||
/** 一条线路近一段时间的 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<SeriesDto>(`/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<number | null>(null)
|
||||
const last = useRef<number | null>(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 (
|
||||
<div className="strips" onMouseLeave={() => setHover(null)}>
|
||||
<Strip
|
||||
{...common}
|
||||
rttLabel="UDP 延迟 p50"
|
||||
rtt={m['rtt.p50']}
|
||||
badLabel="丢包"
|
||||
bad={m['loss.total']}
|
||||
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
|
||||
badSeries={col('loss.total')}
|
||||
rttSeries={col('rtt.p50')}
|
||||
tipBad="丢包"
|
||||
/>
|
||||
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} rttSeries={col('tcp.rtt.p50')} tipBad="重传" />
|
||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||
{at !== null ? (
|
||||
<>
|
||||
<b className="pop-time">
|
||||
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
|
||||
</b>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">UDP</span>
|
||||
<span>
|
||||
丢包 <em className={levelOf(col('loss.total')[at])}>{fmtPct(col('loss.total')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
延迟 p50 <em>{fmtMs(col('rtt.p50')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">TCP</span>
|
||||
<span>
|
||||
重传 <em className={levelOf(col('tcp.retrans')[at])}>{fmtPct(col('tcp.retrans')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+288
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user