chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 26620707a0
347 files changed
+56172

No files matched your search

+213
View File
@@ -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>
)
}
+254
View File
@@ -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>
)
}
+188
View File
@@ -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>
)
}