feat(display): add compact QUIC and host health views
This commit is contained in:
1 parent
b964dc2bbc
commit
b25b111134
14 files changed
+921
-251
No files matched your search
@@ -21,9 +21,11 @@ interface MetricInfo {
|
||||
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: 'UDP 丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.total', label: 'QUIC 丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'QUIC 上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: 'QUIC 下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.run.up_max', label: 'QUIC 上行最长连续丢包', unit: '', better: 'lower' },
|
||||
{ id: 'loss.run.down_max', label: 'QUIC 下行最长连续丢包', 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' },
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import { TimeChart } from '@shared/charts/TimeChart'
|
||||
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts } from '../live'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, Segmented, Spinner, Stat } from '@shared/ui'
|
||||
import { fmtAgo } from '@shared/format'
|
||||
import { OsIcon } from '@shared/osIcon'
|
||||
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
|
||||
import { PageError } from '../lib'
|
||||
|
||||
interface HostHistory {
|
||||
t: number[]
|
||||
values: Record<string, (number | null)[]>
|
||||
}
|
||||
|
||||
const RANGES = [
|
||||
{ value: '1h', label: '近 1 小时' },
|
||||
{ value: '6h', label: '近 6 小时' },
|
||||
{ value: '24h', label: '近 24 小时' },
|
||||
{ value: '7d', label: '近 7 天' },
|
||||
] as const
|
||||
|
||||
export function Host() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const range = RANGES.find((item) => item.value === params.get('range'))?.value ?? '24h'
|
||||
const { hosts, live } = useHosts()
|
||||
const infos = useNodeInfos()
|
||||
const node = hosts.get(id)
|
||||
const info = infos.get(id)
|
||||
const history = useQuery(() => api.get<HostHistory>(`/api/public/hosts/${encodeURIComponent(id)}`, { range }), [id, range], 30_000)
|
||||
const data = history.data
|
||||
const usage = useMemo(() => data ? [
|
||||
{ label: 'CPU', values: data.values.cpu ?? [], color: '--chart-1' },
|
||||
{ label: '内存', values: data.values.mem ?? [], color: '--chart-2' },
|
||||
{ label: '磁盘', values: data.values.disk ?? [], color: '--chart-4' },
|
||||
] : [], [data])
|
||||
const network = useMemo(() => data ? [
|
||||
{ label: '上行', values: data.values.tx_bps ?? [], color: '--chart-2' },
|
||||
{ label: '下行', values: data.values.rx_bps ?? [], color: '--chart-1' },
|
||||
] : [], [data])
|
||||
const host = node?.host
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="crumbs"><Link to="/" className="row"><ChevronLeft size={16} />总览</Link></div>
|
||||
<section className="hero">
|
||||
<h1>{node?.name ?? id}</h1>
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
{info?.country ? <Flag code={info.country} /> : null}
|
||||
{info?.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||||
{info?.os_name ? <span className="row"><OsIcon os={info.os} name={info.os_name} />{[info.os_name, info.arch].filter(Boolean).join(' / ')}</span> : null}
|
||||
{node ? <><Chip>{node.kind === 'agent' ? 'Agent' : 'Probe'}</Chip><Dot tone={node.online ? 'ok' : 'bad'} /><span className="muted">{node.online ? (node.stale ? '状态数据暂未更新' : '在线') : '离线'}</span></> : <span className="muted">节点信息加载中…</span>}
|
||||
{node?.last_seen_at && !node.online ? <span className="faint">最后在线 {fmtAgo(node.last_seen_at)}</span> : null}
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PageError error={history.error} />
|
||||
{host ? <section className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="tiles">
|
||||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(1)} unit="%" />
|
||||
<Stat label="内存" value={pct(host.mem_used, host.mem_total)?.toFixed(1) ?? '—'} unit="%" />
|
||||
<Stat label="磁盘" value={pct(host.disk_used, host.disk_total)?.toFixed(1) ?? '—'} unit="%" />
|
||||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||||
<Stat label="当前网速 ↑ / ↓" value={`${fmtSpeed(node?.online ? host.net_tx_bps : null)} / ${fmtSpeed(node?.online ? host.net_rx_bps : null)}`} />
|
||||
<Stat label="链路速率" value={host.net_link_mbps ? `${host.net_link_mbps} Mbit/s` : '—'} />
|
||||
<Stat label="运行时间" value={fmtUptime(host.uptime_seconds)} />
|
||||
<Stat label="状态更新时间" value={fmtAgo(host.at)} />
|
||||
</div>
|
||||
</section> : <p className="faint" style={{ marginTop: 24 }}>尚无主机资源快照;节点上线并上报状态后会显示实时数据。</p>}
|
||||
|
||||
<section className="card host-history" style={{ marginTop: 20 }}>
|
||||
<div className="agent-head">
|
||||
<span className="agent-name">资源历史</span>
|
||||
<span className="right"><Segmented options={RANGES.map((item) => item)} value={range} onChange={(value) => setParams((prev) => { const next = new URLSearchParams(prev); next.set('range', value); return next }, { replace: true })} /></span>
|
||||
{history.loading ? <Spinner /> : null}
|
||||
</div>
|
||||
{data ? <div className="grid cols-2 host-history-charts">
|
||||
<div><h2>CPU、内存与磁盘</h2><TimeChart t={data.t} unit="%" series={usage} /></div>
|
||||
<div><h2>网络吞吐</h2><TimeChart t={data.t} unit=" B/s" series={network} /></div>
|
||||
</div> : !history.loading ? <p className="faint">所选时间段暂无历史数据。</p> : null}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+116
-141
@@ -1,5 +1,5 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
@@ -7,7 +7,6 @@ import type { LineDto, TestDto } from '@shared/api/types'
|
||||
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
||||
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { fmtBytes, useHosts } from '../live'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
@@ -24,28 +23,25 @@ function tone(value: number | null | undefined, good: number, poor: number): 'ok
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
interface HostSeries {
|
||||
t: number[]
|
||||
values: Record<string, (number | null)[]>
|
||||
}
|
||||
|
||||
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
|
||||
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
|
||||
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
|
||||
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
|
||||
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' },
|
||||
{ label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs },
|
||||
{ label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs },
|
||||
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
|
||||
type MetricGroup = 'quic' | 'tcp-connect' | 'score'
|
||||
|
||||
const METRIC_GROUPS: { value: MetricGroup; label: string }[] = [
|
||||
{ value: 'quic', label: 'QUIC' },
|
||||
{ value: 'tcp-connect', label: 'TCP' },
|
||||
{ value: 'score', label: '评分' },
|
||||
]
|
||||
|
||||
const QUIC_METRICS = 'rtt.p50,rtt.p95,rtt.p99,loss.total,loss.up,loss.down,loss.run.up_max,loss.run.down_max,jitter.up.p95,jitter.down.p95'
|
||||
|
||||
const TILES: { group: Exclude<MetricGroup, 'score'>; label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
|
||||
{ group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ group: 'tcp-connect', label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ group: 'tcp-connect', label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ group: 'tcp-connect', label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
|
||||
]
|
||||
|
||||
interface ChartDef {
|
||||
@@ -55,26 +51,26 @@ interface ChartDef {
|
||||
zero?: boolean
|
||||
fill?: boolean
|
||||
series: { key: string; label: string; color: string; dash?: number[] }[]
|
||||
group: 'udp' | 'tcp' | 'connect' | 'score'
|
||||
group: MetricGroup
|
||||
/** 占满一行。 */
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 'udp', title: 'UDP(往返探测)' },
|
||||
{ id: 'tcp', title: 'TCP' },
|
||||
{ id: 'connect', title: '建连(TCP 握手、TLS 握手)' },
|
||||
{ id: 'quic', title: 'QUIC Datagram' },
|
||||
{ id: 'tcp-connect', title: 'TCP' },
|
||||
{ id: 'score', title: '评分' },
|
||||
] as const
|
||||
|
||||
const CHARTS: ChartDef[] = [
|
||||
{ id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
|
||||
{ id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||||
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
|
||||
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
||||
{ id: 'connect-time', title: '建连耗时(p95)', group: 'connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
|
||||
{ id: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
|
||||
{ id: 'rtt', title: '延迟', group: 'quic', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
|
||||
{ id: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||||
{ id: 'loss', title: '丢包', group: 'quic', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||||
{ id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '上行', color: '--chart-2' }, { key: 'loss.run.down_max', label: '下行', color: '--chart-3' }] },
|
||||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
|
||||
{ id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
||||
{ id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
|
||||
{ id: 'connect-ok', title: '建连成功率', group: 'tcp-connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
|
||||
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
|
||||
]
|
||||
|
||||
@@ -100,58 +96,10 @@ function ewma(values: (number | null)[], alpha: number): (number | null)[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
|
||||
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
|
||||
const history = useQuery(
|
||||
() => (agentId && range ? api.get<HostSeries>(`/api/public/hosts/${agentId}`, { range }) : Promise.resolve(undefined)),
|
||||
[agentId, range],
|
||||
30_000,
|
||||
)
|
||||
const { hosts } = useHosts()
|
||||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
||||
const data = history.data
|
||||
const charts = useMemo(
|
||||
() =>
|
||||
data
|
||||
? {
|
||||
usage: [
|
||||
{ label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' },
|
||||
{ label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' },
|
||||
{ label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' },
|
||||
],
|
||||
speed: [
|
||||
{ label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' },
|
||||
{ label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
[data],
|
||||
)
|
||||
if (!host && !data) return null
|
||||
return (
|
||||
<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}
|
||||
{data && charts ? (
|
||||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
||||
<TimeChart t={data.t} unit="%" series={charts.usage} />
|
||||
<TimeChart t={data.t} unit=" B/s" series={charts.speed} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Line() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [metricGroup, setMetricGroup] = useState<MetricGroup>('quic')
|
||||
|
||||
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
|
||||
const infos = useNodeInfos()
|
||||
@@ -162,7 +110,13 @@ export function Line() {
|
||||
|
||||
const spec = readSpec(params)
|
||||
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
|
||||
const series = usePeriodSeries(id, ready, period)
|
||||
const series = usePeriodSeries(
|
||||
id,
|
||||
ready,
|
||||
period,
|
||||
metricGroup === 'score' ? 'scores' : metricGroup === 'tcp-connect' ? 'tcp,connect' : metricGroup,
|
||||
metricGroup === 'quic' ? QUIC_METRICS : undefined,
|
||||
)
|
||||
|
||||
const update = (fn: (q: URLSearchParams) => void) => {
|
||||
const q = new URLSearchParams(params)
|
||||
@@ -198,7 +152,6 @@ export function Line() {
|
||||
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
|
||||
})
|
||||
|
||||
const agentId = line.data?.agent_id
|
||||
const lastTestId = line.data?.last_test?.id
|
||||
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
|
||||
|
||||
@@ -211,7 +164,7 @@ export function Line() {
|
||||
const charts = useMemo(
|
||||
() =>
|
||||
s
|
||||
? GROUPS.map((group) => {
|
||||
? GROUPS.filter((group) => group.id === metricGroup).map((group) => {
|
||||
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
|
||||
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
|
||||
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
|
||||
@@ -223,20 +176,19 @@ export function Line() {
|
||||
]
|
||||
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
|
||||
if (!list.some((x) => x.values.some((v) => v !== null))) return null
|
||||
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={def.wide ? 260 : 220} /> }
|
||||
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={metricGroup === 'quic' ? 220 : def.wide ? 230 : 190} /> }
|
||||
})
|
||||
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
|
||||
}).filter((g) => g.cards.length > 0)
|
||||
: [],
|
||||
[s, gaps, smooth.alpha],
|
||||
[s, gaps, smooth.alpha, metricGroup],
|
||||
)
|
||||
const loadingNow = !ready || series.busy
|
||||
const chartSections = charts.map(({ group, cards }) => (
|
||||
<section className="chart-section" key={group.id}>
|
||||
<h2>{group.title}</h2>
|
||||
<div className="grid cols-2">
|
||||
<div className={`grid cols-2 ${group.id === 'quic' ? 'quic-chart-grid' : ''}`}>
|
||||
{cards.map(({ def, node }) => (
|
||||
<div className={`card chart-card has-loading ${def.wide ? 'wide' : ''}`} key={def.id}>
|
||||
<div className={`card chart-card has-loading ${def.wide || cards.length === 1 ? 'wide' : ''}`} key={def.id}>
|
||||
<LoadingBar active={loadingNow} />
|
||||
<h3>{def.title}</h3>
|
||||
{node}
|
||||
@@ -250,7 +202,7 @@ export function Line() {
|
||||
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<main className="page line-page">
|
||||
<div className="crumbs">
|
||||
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
||||
<ChevronLeft size={16} />
|
||||
@@ -258,12 +210,13 @@ export function Line() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="hero">
|
||||
<h1>
|
||||
{line.data?.name ?? '线路'}
|
||||
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
|
||||
</h1>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<section className="hero line-header-surface">
|
||||
<div className="line-title-row">
|
||||
<h1>
|
||||
{line.data?.name ?? '线路'}
|
||||
{line.data ? <span className="muted line-peer-name"> → {line.data.probe_name}</span> : null}
|
||||
</h1>
|
||||
<div className="row line-version-switch">
|
||||
{IP_VERSIONS.map((v) => {
|
||||
const query = new URLSearchParams(params)
|
||||
query.set('ip_version', v.value)
|
||||
@@ -274,11 +227,12 @@ export function Line() {
|
||||
)
|
||||
})}
|
||||
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{line.data ? (
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<div className="row wrap line-route-meta">
|
||||
<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 没能和它建立测量会话;机器可能是通的,ping 通不代表测量用的 TCP 和 UDP 通道可用)') : 'Agent 离线'}</span>
|
||||
<span className="line-route-status">{line.data.online ? (line.data.probe_connected ? '在线' : '测量未连接') : '离线'}</span>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="faint">Agent</span>
|
||||
<NodeTag info={infos.get(line.data.agent_id)} />
|
||||
@@ -293,7 +247,7 @@ export function Line() {
|
||||
{series.loading && !s ? <Spinner /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="line-range-picker">
|
||||
<RangePicker
|
||||
dayStart={period.dayStart}
|
||||
fromMs={period.fromMs}
|
||||
@@ -305,6 +259,16 @@ export function Line() {
|
||||
sinceMs={sinceMs}
|
||||
nowMs={nowMs}
|
||||
spanMs={period.spanMs}
|
||||
toolbar={
|
||||
<div className="line-toolbar-group line-smoothing">
|
||||
<span className="line-section-label" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">平滑</span>
|
||||
<Segmented<EwmaValue>
|
||||
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
|
||||
value={smooth.value}
|
||||
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Minimap
|
||||
domainStart={period.domainStart}
|
||||
@@ -346,35 +310,62 @@ export function Line() {
|
||||
) : null}
|
||||
|
||||
{s ? (
|
||||
<div className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="tiles">
|
||||
<div className="tile scores-tile has-loading">
|
||||
<LoadingBar active={loadingNow} />
|
||||
<span className="k">评分</span>
|
||||
<ScoreChips metrics={m} />
|
||||
<div className="stack line-metrics" style={{ marginTop: 16 }}>
|
||||
<section className="line-metric-surface">
|
||||
<header className="line-metric-header">
|
||||
<div className="line-metric-heading">
|
||||
<h2>{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}</h2>
|
||||
{metricGroup === 'quic' ? <span>往返时延 · 双向丢包 · 抖动</span> : null}
|
||||
</div>
|
||||
<Segmented<MetricGroup> options={METRIC_GROUPS} value={metricGroup} onChange={setMetricGroup} />
|
||||
</header>
|
||||
{metricGroup === 'quic' ? <>
|
||||
<div className="line-quic-summary">
|
||||
<div className="line-combo-card">
|
||||
<h3>QUIC RTT</h3>
|
||||
<div className="line-combo-primary"><span>p50</span><strong>{fmtMs(m['rtt.p50'])}</strong></div>
|
||||
<div className="line-combo-secondary"><span>p95 <b>{fmtMs(m['rtt.p95'])}</b></span><span className="combo-divider">|</span><span>p99 <b>{fmtMs(m['rtt.p99'])}</b></span></div>
|
||||
</div>
|
||||
<div className="line-combo-card">
|
||||
<h3>QUIC Datagram 丢包</h3>
|
||||
<div className="line-combo-primary"><span>总丢包</span><strong className={`combo-tone-${tone(m['loss.total'], 0.5, 3)}`}>{fmtPct(m['loss.total'])}</strong></div>
|
||||
<div className="line-combo-secondary"><span>↑ {fmtPct(m['loss.up'])}</span><span>/</span><span>↓ {fmtPct(m['loss.down'])}</span></div>
|
||||
<span className="line-combo-badge">最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包</span>
|
||||
</div>
|
||||
<div className="line-combo-card">
|
||||
<h3>QUIC 抖动 p95</h3>
|
||||
<div className="line-jitter-pair"><span>↑ <b>{fmtMs(m['jitter.up.p95'])}</b></span><span>↓ <b>{fmtMs(m['jitter.down.p95'])}</b></span></div>
|
||||
</div>
|
||||
</div>
|
||||
{TILES.filter((t) => !t.optional || m[t.key] != null).map((t) => (
|
||||
<div className="line-session-bar" title="会话计数从 Probe 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
|
||||
<strong className="line-session-title">QUIC 会话与传输</strong>
|
||||
{line.data?.quic_health ? (() => {
|
||||
const h = line.data.quic_health!
|
||||
return <>
|
||||
<div className="line-session-items">
|
||||
<span>握手 p95 <b>{fmtMs(h.handshake_p95_ms)}</b></span>
|
||||
<span>成功率 <b>{fmtPct(h.handshake_success_rate)}</b></span>
|
||||
<span>超时 <b>{fmtPct(h.handshake_timeout_rate)}</b></span>
|
||||
<span>失败 <b>{fmtPct(h.handshake_failure_rate)}</b></span>
|
||||
<span>传输疑似丢包 <b>{fmtPct(h.transport_packet_loss_rate)}</b></span>
|
||||
<span>路径 MTU <b>{fmtNum(h.path_mtu)} B</b></span>
|
||||
</div>
|
||||
</>
|
||||
})() : <span className="line-session-empty">Probe 尚未上报会话数据</span>}
|
||||
</div>
|
||||
</> : null}
|
||||
{metricGroup !== 'quic' ? <div className="tiles">
|
||||
{metricGroup === 'score' ? <div className="tile scores-tile has-loading"><LoadingBar active={loadingNow} /><span className="k">评分</span><ScoreChips metrics={m} /></div> : null}
|
||||
{TILES.filter((t) => t.group === metricGroup && (!t.optional || m[t.key] != null)).map((t) => (
|
||||
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
|
||||
))}
|
||||
</div>
|
||||
<p className="faint">
|
||||
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
|
||||
</p>
|
||||
{s.summary.coverage < 0.95 ? <p className="faint">这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
|
||||
<span className="faint" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">
|
||||
EWMA 平滑
|
||||
</span>
|
||||
<Segmented<EwmaValue>
|
||||
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
|
||||
value={smooth.value}
|
||||
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
|
||||
/>
|
||||
</div> : null}
|
||||
<div className="line-data-notes">
|
||||
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}</span>
|
||||
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
|
||||
</div>
|
||||
{chartSections}
|
||||
|
||||
<HostCard agentId={agentId} range={period.hostRange} />
|
||||
</section>
|
||||
|
||||
{test.data && result ? (
|
||||
<div className="card">
|
||||
@@ -392,22 +383,6 @@ export function Line() {
|
||||
</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>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PageError } from '../lib'
|
||||
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
||||
|
||||
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
||||
const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
||||
|
||||
type SortKey = 'name' | 'rtt' | 'loss'
|
||||
|
||||
@@ -53,7 +53,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
||||
const stale = !node.online || node.stale
|
||||
const info = infos.get(node.id)
|
||||
return (
|
||||
<div className={`card agent-card ${node.online ? '' : 'offline'}`}>
|
||||
<Link to={`/host/${encodeURIComponent(node.id)}`} className={`card agent-card host-link ${node.online ? '' : 'offline'}`} aria-label={`查看 ${node.name} 主机状态`}>
|
||||
<div className="agent-head">
|
||||
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
||||
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
|
||||
@@ -133,11 +133,11 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
||||
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条线路(Probe → Agent):近一段时间的 UDP 丢包和延迟走势、TCP 的 RTT 和重传。 */
|
||||
/** 一条线路(Probe → Agent):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
|
||||
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
||||
const probeInfo = infos.get(l.probe_id)
|
||||
const agentInfo = infos.get(l.agent_id)
|
||||
@@ -153,7 +153,7 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang
|
||||
<span className="name">{l.name}</span>
|
||||
</span>
|
||||
{!l.probe_connected && l.online ? (
|
||||
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 和 UDP 通道,线路或运营商对它们的处理可能和 ping 不一样。">
|
||||
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 控制通道和 QUIC Datagram 数据通道,线路或运营商对它们的处理可能和 ping 不一样。">
|
||||
<Chip tone="warn">测量通道未建立</Chip>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user