tcp.retrans.up counts retransmitted segments sent by the dialing side, tcp.retrans.down those sent by the listening side. The cards show RTT as a round trip (⇄) with forward (→) and return (←) rows for QUIC loss and TCP retransmission.
313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { Link, useSearchParams } from 'react-router-dom'
|
||
import { api } from '@shared/api/client'
|
||
import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types'
|
||
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 { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
|
||
import { OsIcon } from '@shared/osIcon'
|
||
import { PageError } from '../lib'
|
||
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
||
|
||
const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans', 'tcp.retrans.up', 'tcp.retrans.down'].join(',')
|
||
|
||
type SortKey = 'name' | 'rtt' | 'loss'
|
||
|
||
/** 线路按 来源 Agent 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
|
||
const CARRIERS = [
|
||
{ key: 'telecom', name: '电信' },
|
||
{ key: 'unicom', name: '联通' },
|
||
{ key: 'mobile', name: '移动' },
|
||
{ key: 'other', name: '其他' },
|
||
] as const
|
||
type CarrierKey = (typeof CARRIERS)[number]['key']
|
||
const carrierOf = (info: NodeInfoDto | undefined): CarrierKey => (info?.isp_key === 'telecom' || info?.isp_key === 'unicom' || info?.isp_key === 'mobile' ? info.isp_key : 'other')
|
||
|
||
const average = (values: Array<number | null | undefined>) => {
|
||
const v = values.filter((x): x is number => typeof x === 'number')
|
||
return v.length === 0 ? null : v.reduce((a, b) => a + b, 0) / v.length
|
||
}
|
||
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
/** 一个节点(Agent)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
|
||
function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; infos: Map<string, NodeInfoDto> }) {
|
||
const h = node.host
|
||
const stale = !node.online || node.stale
|
||
const info = infos.get(node.id)
|
||
return (
|
||
<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'} />}
|
||
<span className="agent-name">{node.name}</span>
|
||
<span className="faint kind-tag">Agent</span>
|
||
<Chip tone={node.online ? 'ok' : 'bad'}>{node.online ? '在线' : '离线'}</Chip>
|
||
{!node.online && node.last_seen_at ? <span className="faint">{fmtAgo(node.last_seen_at)}</span> : null}
|
||
</div>
|
||
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
|
||
<div className="facts">
|
||
{info.isp_name ? (
|
||
<div className="fact">
|
||
<span className="k">运营商</span>
|
||
<span className="v">
|
||
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||
{info.isp_name}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{info.os_name ? (
|
||
<div className="fact">
|
||
<span className="k">系统</span>
|
||
<span className="v">
|
||
<OsIcon os={info.os} name={info.os_name} />
|
||
{[info.os_name, info.arch].filter(Boolean).join(' / ')}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{info.tag || info.version ? (
|
||
<div className="fact">
|
||
<span className="k">版本</span>
|
||
<span className="v mono-tag" title={info.version ? `版本 ${info.version}` : undefined}>
|
||
{info.tag || info.version}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{labels.length > 0 ? (
|
||
<div className="line-meta">
|
||
{labels.map((x) => (
|
||
<Chip key={x}>{x}</Chip>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<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(node.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(node.online ? h.net_rx_bps : null)}
|
||
</span>
|
||
</div>
|
||
<div className="stat">
|
||
<span className="k">链路速率</span>
|
||
<span className="num">{h.net_link_mbps ? `${h.net_link_mbps} Mbit/s` : '—'}</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>
|
||
</Link>
|
||
)
|
||
}
|
||
|
||
/** 一条线路(拨号方 ⇄ 监听方):RTT 是往返;去程是拨号方到监听方(左到右),回程是监听方到拨号方(右到左)。QUIC 看两个方向的丢包,TCP 看两个方向的重传。 */
|
||
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
||
const dialerInfo = infos.get(l.dialer_id)
|
||
const agentInfo = infos.get(l.agent_id)
|
||
const rangeLabel = OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range
|
||
const connectionIssue = !l.online
|
||
? 'Agent 离线'
|
||
: l.quic_health?.control_state === 'backoff'
|
||
? '与 Agent 失联'
|
||
: l.quic_health?.state === 'retry_wait'
|
||
? 'QUIC 拨号失败'
|
||
: !l.dialer_connected
|
||
? '测量通道未建立'
|
||
: l.quic_health && !l.quic_health.connected
|
||
? 'QUIC 正在连接'
|
||
: null
|
||
return (
|
||
<Link to={`/line/${encodeURIComponent(l.id)}`} className="line-link line-card">
|
||
<div className="line-top">
|
||
<span className="line-name" title={[l.dialer_name, dialerInfo?.tag || dialerInfo?.version].filter(Boolean).join(' · ')}>
|
||
{dialerInfo?.country ? <Flag code={dialerInfo.country} /> : null}
|
||
<span className="name">{l.dialer_name}</span>
|
||
{dialerInfo?.isp_key ? <IspIcon ispKey={dialerInfo.isp_key} name={dialerInfo.isp_name} /> : null}
|
||
<span className="arrow" title="RTT 是往返;去程:左 → 右,回程:右 → 左">⇄</span>
|
||
{agentInfo?.country ? <Flag code={agentInfo.country} /> : null}
|
||
<span className="name">{l.name}</span>
|
||
</span>
|
||
<span
|
||
className={connectionIssue ? 'line-range connection-issue' : 'faint line-range'}
|
||
title={l.quic_health?.state === 'retry_wait' ? `来源 Agent 正在等待退避重试${l.quic_health.retry_in_ms ? `(约 ${Math.ceil(l.quic_health.retry_in_ms / 1000)} 秒)` : ''}。` : !l.dialer_connected && l.online ? 'Agent 在线,但 来源 Agent 尚未建立测量会话。' : undefined}
|
||
>
|
||
{connectionIssue ? `${connectionIssue} · ${rangeLabel}` : rangeLabel}
|
||
</span>
|
||
</div>
|
||
<LineStrips lineId={l.id} range={range} m={l.summary.metrics} />
|
||
</Link>
|
||
)
|
||
}
|
||
|
||
export function Overview() {
|
||
const [carrier, setCarrier] = useState<'' | CarrierKey>('')
|
||
const [sort, setSort] = useState<SortKey>('name')
|
||
const { hosts, live } = useHosts()
|
||
const infos = useNodeInfos()
|
||
// 汇总和走势使用同一个时间范围,记在链接里(例如 ?range=48h)。
|
||
const [params, setParams] = useSearchParams()
|
||
const ipVersion = ipVersionOf(params.get('ip_version'))
|
||
const ipv6 = useQuery(() => ipVersion === 'ipv6' ? api.get<Page<AgentIpv6Dto>>('/api/public/ipv6') : Promise.resolve(undefined), [ipVersion], 15_000)
|
||
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, ip_version: ipVersion }), [range, ipVersion], 10_000)
|
||
|
||
// 节点卡片:在线节点在前、再按名字;Agent 的标签来自它的线路
|
||
const nodes = useMemo(() => {
|
||
const labels = new Map<string, string[]>()
|
||
for (const l of data?.lines ?? []) labels.set(l.agent_id, l.labels)
|
||
return [...hosts.values()]
|
||
.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
|
||
.map((node) => ({ node, labels: labels.get(node.id) ?? [] }))
|
||
}, [hosts, data])
|
||
// 线路面板:按 IP 版本过滤,再排序
|
||
const lines = useMemo(() => {
|
||
const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
|
||
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
|
||
return picked.sort((a, b) => {
|
||
if (a.online !== b.online) return a.online ? -1 : 1
|
||
const byName = () => a.name.localeCompare(b.name) || a.dialer_name.localeCompare(b.dialer_name)
|
||
if (sort === 'name') return byName()
|
||
const x = a.summary.metrics[key]
|
||
const y = b.summary.metrics[key]
|
||
if ((x === null || x === undefined) && (y === null || y === undefined)) return byName()
|
||
if (x === null || x === undefined) return 1
|
||
if (y === null || y === undefined) return -1
|
||
return x - y
|
||
})
|
||
}, [data, sort, ipVersion])
|
||
// 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示)
|
||
const groups = useMemo(
|
||
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.dialer_id)) === c.key) })).filter((g) => g.lines.length > 0),
|
||
[lines, infos],
|
||
)
|
||
const shown = groups.filter((g) => !carrier || g.key === carrier)
|
||
|
||
const count = () => {
|
||
const all = nodes
|
||
return `${all.filter((n) => n.node.online).length} / ${all.length}`
|
||
}
|
||
|
||
return (
|
||
<main className="page overview-page">
|
||
<section className="hero">
|
||
<h1>总览</h1>
|
||
<p className="sub">
|
||
{hosts.size > 0 || data ? `Agent ${count()} 在线` : '加载中…'}
|
||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||
</p>
|
||
</section>
|
||
|
||
<PageError error={error} />
|
||
|
||
{nodes.length > 0 ? (
|
||
<div className="cards">
|
||
{nodes.map(({ node, labels }) => (
|
||
<NodeCard key={node.id} node={node} labels={labels} infos={infos} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<section className="card agent-card lines-panel">
|
||
<div className="agent-head">
|
||
<span className="agent-name">线路</span>
|
||
<span className="faint">{data ? `${lines.length} 条` : ''}</span>
|
||
{loading && !data ? <Spinner /> : null}
|
||
</div>
|
||
<div className="toolbar lines-toolbar">
|
||
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setCarrier(''); setParams(next, { replace: true }) }} />
|
||
{groups.length > 1 ? <Segmented<'' | CarrierKey> options={[{ value: '', label: '全部' }, ...groups.map((g) => ({ value: g.key, label: g.name }))]} value={carrier} onChange={setCarrier} /> : 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: 'loss' as const, label: '丢包' },
|
||
]}
|
||
value={sort}
|
||
onChange={setSort}
|
||
/>
|
||
</span>
|
||
</div>
|
||
{data ? (
|
||
lines.length === 0 ? (
|
||
<div>
|
||
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
|
||
{ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) => <p key={a.agent_id} className="muted">{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}</p>) : null}
|
||
</div>
|
||
) : (
|
||
shown.map((g) => {
|
||
const rtt = average(g.lines.map((l) => l.summary.metrics['rtt.p50']))
|
||
const loss = average(g.lines.map((l) => l.summary.metrics['loss.total']))
|
||
return (
|
||
<div key={g.key} className="carrier-group">
|
||
<div className="carrier-head">
|
||
{g.key !== 'other' ? <IspIcon ispKey={g.key} name={g.name} /> : null}
|
||
<b>{g.name}</b>
|
||
<span className="faint">{g.lines.length} 条</span>
|
||
<span className="right carrier-sum num">
|
||
<span className="faint">平均延迟</span> {rtt === null ? '—' : `${rtt.toFixed(1)} ms`}
|
||
<span className="faint">平均丢包</span> {loss === null ? '—' : `${loss.toFixed(2)}%`}
|
||
</span>
|
||
</div>
|
||
<div className="line-grid">
|
||
{g.lines.map((l) => (
|
||
<LineCard key={l.id} l={l} range={range} infos={infos} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
)
|
||
) : null}
|
||
</section>
|
||
</main>
|
||
)
|
||
}
|