chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import { Star } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
|
||||
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
||||
import { fmtMs, fmtPct } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Empty, Segmented, Spinner } from '@shared/ui'
|
||||
import { lineLabel, PageError } from '../lib'
|
||||
import { PRESETS, type PresetValue } from '../ranges'
|
||||
|
||||
interface MetricInfo {
|
||||
id: string
|
||||
label: string
|
||||
unit: 'ms' | '%' | ''
|
||||
better: 'lower' | 'higher'
|
||||
}
|
||||
|
||||
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: '丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: '下行丢包', 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' },
|
||||
{ id: 'tcp.retrans', label: 'TCP 重传', unit: '%', better: 'lower' },
|
||||
{ id: 'score.reality', label: 'REALITY 评分', unit: '', better: 'higher' },
|
||||
{ id: 'score.hy2', label: 'Hy2 评分', unit: '', better: 'higher' },
|
||||
{ id: 'score.mc', label: 'MC 评分', unit: '', better: 'higher' },
|
||||
{ id: 'score.base', label: '综合评分', unit: '', better: 'higher' },
|
||||
]
|
||||
|
||||
const COLORS = ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6']
|
||||
const MAX_LINES = 6
|
||||
|
||||
const isPreset = (v: string | null): v is PresetValue => PRESETS.some((p) => p.value === v)
|
||||
|
||||
function fmt(m: MetricInfo, v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—'
|
||||
return m.unit === 'ms' ? fmtMs(v) : m.unit === '%' ? fmtPct(v) : v.toFixed(0)
|
||||
}
|
||||
|
||||
interface Cell {
|
||||
line: OverviewLineDto
|
||||
value: number | null
|
||||
/** 在这一列(同一个 Probe)里的名次,从 1 开始;没有数据是 null。 */
|
||||
rank: number | null
|
||||
/** 比这一列最好的差多少(百分比);最好的是 0。 */
|
||||
gap: number | null
|
||||
}
|
||||
|
||||
export function Compare() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d'
|
||||
const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0]
|
||||
const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params])
|
||||
|
||||
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000)
|
||||
|
||||
// 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好)
|
||||
const matrix = useMemo(() => {
|
||||
const lines = overview.data?.lines ?? []
|
||||
const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
const byProbe = new Map<string, Cell[]>()
|
||||
for (const [probeId] of probes) {
|
||||
const cells: Cell[] = lines
|
||||
.filter((l) => l.probe_id === probeId)
|
||||
.map((line) => ({ line, value: line.summary.metrics[metric.id] ?? null, rank: null, gap: null }))
|
||||
const ranked = cells.filter((c) => c.value !== null).sort((a, b) => (metric.better === 'lower' ? a.value! - b.value! : b.value! - a.value!))
|
||||
const best = ranked[0]?.value ?? null
|
||||
ranked.forEach((c, i) => {
|
||||
c.rank = i + 1
|
||||
c.gap = best === null || best === 0 ? (c.value === best ? 0 : null) : (Math.abs(c.value! - best) / Math.abs(best)) * 100
|
||||
})
|
||||
byProbe.set(probeId, cells)
|
||||
}
|
||||
return { probes, agents, byProbe }
|
||||
}, [overview.data, metric])
|
||||
|
||||
// 默认选每个 Probe 里最好的那条线路(最多 6 条)
|
||||
const defaults = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
for (const [probeId] of matrix.probes) {
|
||||
const best = matrix.byProbe.get(probeId)?.find((c) => c.rank === 1)
|
||||
if (best) ids.push(best.line.id)
|
||||
}
|
||||
return ids.slice(0, MAX_LINES)
|
||||
}, [matrix])
|
||||
const selected = picked.length > 0 ? picked : defaults
|
||||
|
||||
const compare = useQuery(
|
||||
() => (selected.length ? api.get<CompareDto>('/api/public/compare', { lines: selected.join(','), metric: metric.id, range }) : Promise.resolve(undefined)),
|
||||
[selected.join(','), metric.id, range],
|
||||
15_000,
|
||||
)
|
||||
|
||||
const update = (patch: Record<string, string | null>) => {
|
||||
const next = new URLSearchParams(params)
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) next.set(k, v)
|
||||
else next.delete(k)
|
||||
}
|
||||
setParams(next, { replace: true })
|
||||
}
|
||||
const toggle = (id: string) => {
|
||||
const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id].slice(-MAX_LINES)
|
||||
update({ lines: next.join(',') })
|
||||
}
|
||||
|
||||
const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.probe_name)]))
|
||||
const series: ChartSeries[] = selected.map((id, i) => ({ label: names.get(id) ?? id, values: compare.data?.values[id] ?? [], color: COLORS[i % COLORS.length] }))
|
||||
|
||||
// 每个 Probe 的最佳线路
|
||||
const winners = matrix.probes.map(([probeId, probeName]) => ({ probeName, cell: matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) }))
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<h1>线路对比</h1>
|
||||
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
|
||||
<div className="toolbar">
|
||||
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
|
||||
<select className="select" style={{ width: 'auto' }} value={metric.id} onChange={(e) => update({ metric: e.target.value })}>
|
||||
{METRICS.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="muted">{metric.better === 'lower' ? '越低越好' : '越高越好'}</span>
|
||||
<span className="right">{overview.loading && !overview.data ? <Spinner /> : null}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PageError error={overview.error ?? compare.error} />
|
||||
|
||||
{overview.data ? (
|
||||
matrix.agents.length === 0 ? (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<Empty>还没有线路</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{winners.some((w) => w.cell) ? (
|
||||
<div className="winners">
|
||||
{winners.map((w) =>
|
||||
w.cell ? (
|
||||
<div key={w.probeName} className="card winner">
|
||||
<span className="faint">从 {w.probeName} 看出去最好</span>
|
||||
<span className="winner-name">
|
||||
<Star size={16} /> {w.cell.line.name}
|
||||
</span>
|
||||
<span className="num muted">{fmt(metric, w.cell.value)}</span>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card" style={{ marginTop: 16, padding: '8px 12px' }}>
|
||||
<table className="table matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent / Probe</th>
|
||||
{matrix.probes.map(([id, name]) => (
|
||||
<th key={id} className="r">
|
||||
{name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrix.agents.map(([agentId, agentName]) => (
|
||||
<tr key={agentId}>
|
||||
<td style={{ fontWeight: 600 }}>{agentName}</td>
|
||||
{matrix.probes.map(([probeId]) => {
|
||||
const cell = matrix.byProbe.get(probeId)?.find((c) => c.line.agent_id === agentId)
|
||||
if (!cell) return <td key={probeId} className="r faint">—</td>
|
||||
const on = selected.includes(cell.line.id)
|
||||
const tone = cell.rank === 1 ? 'ok' : cell.gap !== null && cell.gap > 50 ? 'bad' : cell.gap !== null && cell.gap > 15 ? 'warn' : ''
|
||||
return (
|
||||
<td key={probeId} className="r">
|
||||
<button type="button" className={`cell ${on ? 'on' : ''}`} onClick={() => toggle(cell.line.id)} title={on ? '从图里移除' : '加到下面的图里'}>
|
||||
{cell.rank === 1 ? <Star size={13} /> : null}
|
||||
<Chip tone={tone}>{fmt(metric, cell.value)}</Chip>
|
||||
{cell.rank !== null && cell.rank > 1 && cell.gap !== null ? <small className="faint">+{cell.gap.toFixed(0)}%</small> : null}
|
||||
</button>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="faint" style={{ padding: '4px 12px 8px' }}>
|
||||
★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<div className="card-title">{metric.label} 的走势</div>
|
||||
{compare.data ? <TimeChart t={compare.data.t} series={series} unit={metric.unit === '' ? '' : metric.unit} /> : <Empty>{selected.length ? '加载中…' : '点上面的格子选择线路'}</Empty>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { LineDto, SeriesDto, TestDto } from '@shared/api/types'
|
||||
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
||||
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
||||
import { fmtBytes, useHosts } from '../live'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, Spinner, Stat } from '@shared/ui'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
import { availablePresets, defaultPreset, monthsWithData, PRESETS, RangePicker, resolveRange, SLICES, type PresetValue, type RangeState, type SliceValue } from '../ranges'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
interface HostSeries {
|
||||
t: number[]
|
||||
values: Record<string, (number | null)[]>
|
||||
}
|
||||
|
||||
export function Line() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
|
||||
const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null
|
||||
const nowMs = useMemo(() => Date.now(), [line.data])
|
||||
const spanMs = sinceMs === null ? null : nowMs - sinceMs
|
||||
|
||||
// 时间范围:只提供有数据的档位;URL 里的值如果超出了现有的数据就退回到默认
|
||||
const wanted = params.get('range')
|
||||
const available = availablePresets(spanMs)
|
||||
const preset: PresetValue = PRESETS.some((x) => x.value === wanted) && available.some((x) => x.value === wanted) ? (wanted as PresetValue) : defaultPreset(spanMs)
|
||||
const sliceParam = params.get('slice')
|
||||
const state: RangeState = {
|
||||
preset,
|
||||
month: params.get('month'),
|
||||
slice: SLICES.some((x) => x.value === sliceParam) ? (sliceParam as SliceValue) : 'all',
|
||||
page: Math.max(0, Number(params.get('page') ?? 0) || 0),
|
||||
}
|
||||
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
|
||||
const resolved = resolveRange(state, months, nowMs)
|
||||
const rangeKey = JSON.stringify(resolved.query)
|
||||
const setRange = (next: RangeState) => {
|
||||
const q = new URLSearchParams()
|
||||
if (next.month) {
|
||||
q.set('month', next.month)
|
||||
if (next.slice !== 'all') q.set('slice', next.slice)
|
||||
if (next.page > 0) q.set('page', String(next.page))
|
||||
} else {
|
||||
q.set('range', next.preset)
|
||||
}
|
||||
setParams(q, { replace: true })
|
||||
}
|
||||
|
||||
const series = useQuery(
|
||||
() => (line.data ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(id)}/series`, { ...resolved.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
|
||||
[id, rangeKey, Boolean(line.data)],
|
||||
state.month ? undefined : 10_000,
|
||||
)
|
||||
const agentId = line.data?.agent_id
|
||||
const hostHistory = useQuery(
|
||||
() => (agentId && !state.month ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range: resolved.hostRange }) : Promise.resolve(undefined)),
|
||||
[agentId, resolved.hostRange, Boolean(state.month)],
|
||||
30_000,
|
||||
)
|
||||
const { hosts } = useHosts()
|
||||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
||||
const lastTestId = line.data?.last_test?.id
|
||||
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
|
||||
|
||||
const s = series.data
|
||||
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
||||
const m = s?.summary.metrics ?? {}
|
||||
const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s])
|
||||
const t = s?.t ?? []
|
||||
|
||||
const chart = (title: string, unit: string, list: ChartSeries[], zero = true) =>
|
||||
s && list.some((x) => x.values.some((v) => v !== null)) ? (
|
||||
<div className="card chart-card">
|
||||
<h3>{title}</h3>
|
||||
<TimeChart t={t} series={list} unit={unit} gaps={gaps} zero={zero} />
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const result = test.data?.result
|
||||
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="crumbs">
|
||||
<Link to="/" className="row">
|
||||
<ChevronLeft size={16} />
|
||||
总览
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="hero">
|
||||
<h1>
|
||||
{line.data?.name ?? '线路'}
|
||||
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
|
||||
</h1>
|
||||
{line.data ? (
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,Probe 未连接') : 'Agent 离线'}</span>
|
||||
{line.data.labels.map((x) => (
|
||||
<Chip key={x}>{x}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="toolbar">
|
||||
<RangePicker state={state} onChange={setRange} sinceMs={sinceMs} nowMs={nowMs} />
|
||||
<span className="right row">
|
||||
<span className="muted">{resolved.label}</span>
|
||||
{series.loading && !s ? <Spinner /> : null}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PageError error={line.error ?? series.error} />
|
||||
|
||||
{s ? (
|
||||
<div className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="card">
|
||||
<div className="row wrap" style={{ justifyContent: 'space-between', gap: 24 }}>
|
||||
<div className="grid stats grow">
|
||||
<Stat label="延迟 p50" value={fmtMs(m['rtt.p50'])} />
|
||||
<Stat label="延迟 p95" value={fmtMs(m['rtt.p95'])} />
|
||||
<Stat label="丢包" value={fmtPct(m['loss.total'])} tone={tone(m['loss.total'], 0.5, 3)} />
|
||||
<Stat label="上行丢包" value={fmtPct(m['loss.up'])} tone={tone(m['loss.up'], 0.5, 3)} />
|
||||
<Stat label="下行丢包" value={fmtPct(m['loss.down'])} tone={tone(m['loss.down'], 0.5, 3)} />
|
||||
<Stat label="抖动(上 / 下)" value={`${fmtMs(m['jitter.up.p95'])} / ${fmtMs(m['jitter.down.p95'])}`} />
|
||||
</div>
|
||||
<ScoreChips metrics={m} />
|
||||
</div>
|
||||
<hr className="divider" />
|
||||
<div className="grid stats">
|
||||
<Stat label="TCP RTT p50" value={fmtMs(m['tcp.rtt.p50'])} />
|
||||
<Stat label="TCP RTT p99" value={fmtMs(m['tcp.rtt.p99'])} />
|
||||
<Stat label="TCP RTT 最大" value={fmtMs(m['tcp.rtt.max'])} />
|
||||
<Stat label="TCP 重传" value={fmtPct(m['tcp.retrans'])} tone={tone(m['tcp.retrans'], 0.5, 3)} />
|
||||
<Stat label="最长连续丢包" value={fmtNum(m['loss.run_max'])} unit="包" />
|
||||
<Stat label="样本数(UDP / TCP)" value={`${fmtNum(s.summary.samples['udp_echo'])} / ${fmtNum(s.summary.samples['tcp_echo'])}`} />
|
||||
</div>
|
||||
{s.summary.coverage < 0.95 ? <p className="faint" style={{ marginTop: 12 }}>这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
{chart('延迟(UDP 往返)', 'ms', [
|
||||
{ label: 'p50', values: col('rtt.p50'), color: '--chart-1' },
|
||||
{ label: 'p95', values: col('rtt.p95'), color: '--chart-2' },
|
||||
{ label: 'p99', values: col('rtt.p99'), color: '--chart-3' },
|
||||
])}
|
||||
{chart('丢包', '%', [
|
||||
{ label: '上行', values: col('loss.up'), color: '--chart-2' },
|
||||
{ label: '下行', values: col('loss.down'), color: '--chart-3' },
|
||||
{ label: '合计', values: col('loss.total'), color: '--chart-1', dash: [4, 3] },
|
||||
])}
|
||||
{chart('抖动(p95)', 'ms', [
|
||||
{ label: '上行', values: col('jitter.up.p95'), color: '--chart-2' },
|
||||
{ label: '下行', values: col('jitter.down.p95'), color: '--chart-1' },
|
||||
])}
|
||||
{chart('TCP 往返', 'ms', [
|
||||
{ label: 'p50', values: col('tcp.rtt.p50'), color: '--chart-1' },
|
||||
{ label: 'p95', values: col('tcp.rtt.p95'), color: '--chart-2' },
|
||||
{ label: '最大', values: col('tcp.rtt.max'), color: '--chart-3' },
|
||||
])}
|
||||
{chart('TCP 重传', '%', [{ label: '重传率', values: col('tcp.retrans'), color: '--chart-3', fill: true }])}
|
||||
{chart('综合评分', '', [
|
||||
{ label: 'REALITY', values: col('score.reality'), color: '--chart-1' },
|
||||
{ label: 'Hy2', values: col('score.hy2'), color: '--chart-2' },
|
||||
{ label: 'MC', values: col('score.mc'), color: '--chart-4' },
|
||||
{ label: '综合', values: col('score.base'), color: '--chart-5', dash: [4, 3] },
|
||||
])}
|
||||
</div>
|
||||
|
||||
{host || hostHistory.data ? (
|
||||
<div className="card">
|
||||
<div className="card-title">主机状态</div>
|
||||
{host ? (
|
||||
<div className="grid stats">
|
||||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
|
||||
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||||
</div>
|
||||
) : null}
|
||||
{hostHistory.data ? (
|
||||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
||||
<TimeChart
|
||||
t={hostHistory.data.t}
|
||||
unit="%"
|
||||
series={[
|
||||
{ label: 'CPU', values: hostHistory.data.values['cpu'] ?? [], color: '--chart-1' },
|
||||
{ label: '内存', values: hostHistory.data.values['mem'] ?? [], color: '--chart-2' },
|
||||
{ label: '磁盘', values: hostHistory.data.values['disk'] ?? [], color: '--chart-4' },
|
||||
]}
|
||||
/>
|
||||
<TimeChart
|
||||
t={hostHistory.data.t}
|
||||
unit=" B/s"
|
||||
series={[
|
||||
{ label: '上行', values: hostHistory.data.values['tx_bps'] ?? [], color: '--chart-2' },
|
||||
{ label: '下行', values: hostHistory.data.values['rx_bps'] ?? [], color: '--chart-1' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{test.data && result ? (
|
||||
<div className="card">
|
||||
<div className="card-title">最近一次测试 · {test.data.plan}</div>
|
||||
<div className="grid stats">
|
||||
{peak !== undefined ? <Stat label="吞吐峰值" value={fmtMbps(peak)} /> : null}
|
||||
{result.metrics['connect.success'] !== undefined ? <Stat label="建连成功率" value={fmtPct(result.metrics['connect.success'], 1)} /> : null}
|
||||
{result.metrics['connect.tls_ms.p95'] !== undefined ? <Stat label="TLS 握手 p95" value={fmtMs(result.metrics['connect.tls_ms.p95'])} /> : null}
|
||||
{result.metrics['hy2.sustainable_mbps'] !== undefined ? <Stat label="Hy2 可持续码率" value={fmtMbps(result.metrics['hy2.sustainable_mbps'])} /> : null}
|
||||
{result.metrics['hy2.qos_onset_mbps'] !== undefined ? <Stat label="限速起点" value={fmtMbps(result.metrics['hy2.qos_onset_mbps'])} /> : null}
|
||||
</div>
|
||||
<p className="faint" style={{ marginTop: 12 }}>
|
||||
{fmtTime(test.data.finished_at)}({fmtAgo(test.data.finished_at)})
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{s.events.length > 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-title">事件</div>
|
||||
<div className="event-list">
|
||||
{s.events.slice(0, 20).map((e) => (
|
||||
<div key={e.id}>
|
||||
<span className="faint num" style={{ minWidth: 120 }}>
|
||||
{fmtTime(e.at)}
|
||||
</span>
|
||||
<span>{e.kind}</span>
|
||||
{e.detail ? <span className="muted mono">{e.detail}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } 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 { 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'
|
||||
|
||||
const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',')
|
||||
|
||||
type SortKey = 'name' | 'rtt' | 'score'
|
||||
|
||||
interface AgentCard {
|
||||
id: string
|
||||
name: string
|
||||
labels: string[]
|
||||
online: boolean
|
||||
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'
|
||||
return (
|
||||
<div className="meter-row">
|
||||
<span className="meter-label">{label}</span>
|
||||
<span className="meter">
|
||||
<i className={level} style={{ width: `${Math.min(100, Math.max(0, value ?? 0))}%` }} />
|
||||
</span>
|
||||
<span className="meter-value num">{value === null ? '—' : `${value.toFixed(value >= 10 ? 0 : 1)}%`}</span>
|
||||
{detail ? <span className="meter-detail faint num">{detail}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) {
|
||||
const h = host?.host ?? null
|
||||
const stale = !agent.online || !host || host.stale
|
||||
return (
|
||||
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
|
||||
<div className="agent-head">
|
||||
<Dot tone={agent.online ? 'ok' : 'bad'} />
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
|
||||
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
|
||||
</div>
|
||||
{agent.labels.length > 0 ? (
|
||||
<div className="line-meta">
|
||||
{agent.labels.map((x) => (
|
||||
<Chip key={x}>{x}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lines">
|
||||
{agent.lines.map((l) => {
|
||||
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 />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={`host ${stale ? 'stale' : ''}`}>
|
||||
{h ? (
|
||||
<>
|
||||
<Meter label="CPU" value={h.cpu_percent} />
|
||||
<Meter label="内存" value={pct(h.mem_used, h.mem_total)} detail={h.mem_total ? `${fmtBytes(h.mem_used)} / ${fmtBytes(h.mem_total)}` : undefined} />
|
||||
<Meter label="磁盘" value={pct(h.disk_used, h.disk_total)} detail={h.disk_total ? `${fmtBytes(h.disk_used)} / ${fmtBytes(h.disk_total)}` : undefined} />
|
||||
<div className="host-grid">
|
||||
<div className="stat">
|
||||
<span className="k">流量</span>
|
||||
<span className="num">
|
||||
↑ {fmtBytes(h.net_tx_total)} ↓ {fmtBytes(h.net_rx_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="k">网速</span>
|
||||
<span className="num">
|
||||
↑ {fmtSpeed(agent.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(agent.online ? h.net_rx_bps : null)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="k">运行时间</span>
|
||||
<span className="num">{fmtUptime(h.uptime_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="faint">这台 Agent 没有上报主机状态</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
|
||||
const cards = useMemo(() => {
|
||||
const byAgent = new Map<string, AgentCard>()
|
||||
for (const l of data?.lines ?? []) {
|
||||
if (probe && l.probe_name !== probe) continue
|
||||
const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] }
|
||||
card.lines.push(l)
|
||||
byAgent.set(l.agent_id, card)
|
||||
}
|
||||
const best = (c: AgentCard, key: string, pick: (a: number, b: number) => number) => {
|
||||
const values = c.lines.map((l) => l.summary.metrics[key]).filter((v): v is number => v !== null && v !== undefined)
|
||||
return values.length ? values.reduce(pick) : null
|
||||
}
|
||||
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)
|
||||
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
|
||||
})
|
||||
}, [data, probe, sort])
|
||||
|
||||
const online = cards.filter((c) => c.online).length
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<h1>线路质量</h1>
|
||||
<p className="sub">
|
||||
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
</p>
|
||||
<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
|
||||
options={[
|
||||
{ value: 'name' as const, label: '名称' },
|
||||
{ value: 'rtt' as const, label: '延迟' },
|
||||
{ value: 'score' as const, label: '评分' },
|
||||
]}
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
/>
|
||||
{loading && !data ? <Spinner /> : null}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PageError error={error} />
|
||||
|
||||
{data ? (
|
||||
cards.length === 0 ? (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<Empty>还没有线路</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cards">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.id} agent={c} host={hosts.get(c.id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user