Files
MoShiro/web/display/src/pages/Overview.tsx
T
justme 9f5a6cf77a analysis: split TCP retransmissions into forward and return, and show both on the line cards
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.
2026-10-21 16:26:34 +08:00

313 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}