web: unify Agent management and add a draggable link board
Replace Probe pages with directed Agent links, shared deployment controls and unified display data. Add node dragging, canvas pan and zoom, saved layouts, and per-direction link controls.
This commit is contained in:
1 parent
016dbc2bbf
commit
15f0023614
32 files changed
+937
-1093
No files matched your search
@@ -73,7 +73,7 @@ export function LineStrips({ lineId, range, m }: Props) {
|
||||
{series.error ? <span className="line-mini-badge bad" title={`上一次取数失败:${series.error.message}`}>数据未更新</span> : null}
|
||||
</div>
|
||||
<div className="line-mini-direction">
|
||||
<div className="line-mini-direction-head" title={`去程 Probe → Agent 丢包 ${fmtPct(up)}`}>
|
||||
<div className="line-mini-direction-head" title={`去程 拨号方 → 监听方 丢包 ${fmtPct(up)}`}>
|
||||
<span>去程</span>
|
||||
<span className={`line-mini-badge ${levelOf(up)}`}>{upText}</span>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@ export function LineStrips({ lineId, range, m }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="line-mini-direction">
|
||||
<div className="line-mini-direction-head" title={`回程 Agent → Probe 丢包 ${fmtPct(down)}`}>
|
||||
<div className="line-mini-direction-head" title={`回程 监听方 → 拨号方 丢包 ${fmtPct(down)}`}>
|
||||
<span>回程</span>
|
||||
<span className={`line-mini-badge ${levelOf(down)}`}>{downText}</span>
|
||||
</div>
|
||||
|
||||
@@ -1008,7 +1008,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */
|
||||
/* ── 对比:每个 来源 Agent 的最佳线路 + 矩阵 ── */
|
||||
.winners {
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
@@ -1792,7 +1792,7 @@
|
||||
fill: var(--isp-broadnet);
|
||||
}
|
||||
|
||||
/* 线路行:国旗在 Probe 名字前面,运营商图标在后面 */
|
||||
/* 线路行:国旗在 来源 Agent 名字前面,运营商图标在后面 */
|
||||
.line-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -51,6 +51,6 @@ export function PageError({ error }: { error: unknown }) {
|
||||
return <div className="notice bad" style={{ marginTop: 24 }}>{describeError(error)}</div>
|
||||
}
|
||||
|
||||
export const lineLabel = (name: string, probe: string) => `${name} · ${probe}`
|
||||
export const lineLabel = (target: string, source: string) => `${source} → ${target}`
|
||||
|
||||
export const num = (v: number | null | undefined) => (v === null || v === undefined ? null : v)
|
||||
+6
-29
@@ -1,36 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Page } from '@shared/api/types'
|
||||
import type { Page, HostDto, HostItemDto } from '@shared/api/types'
|
||||
import { api } from '@shared/api/client'
|
||||
import { openLive } from '@shared/api/source'
|
||||
|
||||
/** 展示页能拿到的主机状态:只有汇总的数字,没有型号和 IP。 */
|
||||
export interface Host {
|
||||
cpu_percent: number | null
|
||||
mem_used: number | null
|
||||
mem_total: number | null
|
||||
disk_used: number | null
|
||||
disk_total: number | null
|
||||
net_rx_total: number | null
|
||||
net_tx_total: number | null
|
||||
net_rx_bps: number | null
|
||||
net_tx_bps: number | null
|
||||
net_link_mbps: number | null
|
||||
uptime_seconds: number | null
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface HostItem {
|
||||
id: string
|
||||
kind: 'agent' | 'probe'
|
||||
name: string
|
||||
online: boolean
|
||||
stale: boolean
|
||||
last_seen_at: string | null
|
||||
host: Host | null
|
||||
}
|
||||
export type Host = HostDto
|
||||
export type HostItem = HostItemDto
|
||||
|
||||
/**
|
||||
* Agent 和 Probe 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
|
||||
* Agent 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
|
||||
* 连接断开时浏览器会自动重连;重连之后服务端会先推一份当前快照。
|
||||
*/
|
||||
export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
|
||||
@@ -48,11 +25,11 @@ export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
|
||||
source.onopen = () => setLive(true)
|
||||
source.onerror = () => setLive(false)
|
||||
source.addEventListener('host', (event) => {
|
||||
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; kind: 'agent' | 'probe'; online: boolean; host: Host }
|
||||
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; online: boolean; host: Host }
|
||||
setHosts((previous) => {
|
||||
const next = new Map(previous)
|
||||
const old = previous.get(data.id)
|
||||
next.set(data.id, { id: data.id, kind: data.kind, name: old?.name ?? data.id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
|
||||
next.set(data.id, { id: data.id, name: old?.name ?? data.id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
|
||||
return next
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ function fmt(m: MetricInfo, v: number | null | undefined): string {
|
||||
interface Cell {
|
||||
line: OverviewLineDto
|
||||
value: number | null
|
||||
/** 在这一列(同一个 Probe)里的名次,从 1 开始;没有数据是 null。 */
|
||||
/** 在这一行(同一个来源 Agent)里的名次,从 1 开始;没有数据是 null。 */
|
||||
rank: number | null
|
||||
/** 比这一列最好的差多少(百分比);最好的是 0。 */
|
||||
gap: number | null
|
||||
@@ -64,15 +64,15 @@ export function Compare() {
|
||||
|
||||
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id, ip_version: ipVersion }), [range, metric.id, ipVersion], 15_000)
|
||||
|
||||
// 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好)
|
||||
// 矩阵:行是来源 Agent,列是目标 Agent;每一行内部排名(同一个 来源 Agent 看出去,哪个 Agent 的线路好)
|
||||
const matrix = useMemo(() => {
|
||||
const lines = (overview.data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
|
||||
const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
const dialers = [...new Map(lines.map((l) => [l.dialer_id, l.dialer_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 byDialer = new Map<string, Cell[]>()
|
||||
for (const [dialerId] of dialers) {
|
||||
const cells: Cell[] = lines
|
||||
.filter((l) => l.probe_id === probeId)
|
||||
.filter((l) => l.dialer_id === dialerId)
|
||||
.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
|
||||
@@ -80,16 +80,16 @@ export function Compare() {
|
||||
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)
|
||||
byDialer.set(dialerId, cells)
|
||||
}
|
||||
return { probes, agents, byProbe }
|
||||
return { dialers, agents, byDialer }
|
||||
}, [overview.data, metric, ipVersion])
|
||||
|
||||
// 默认选每个 Probe 里最好的那条线路(最多 6 条)
|
||||
// 默认选每个 来源 Agent 里最好的那条线路(最多 6 条)
|
||||
const defaults = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
for (const [probeId] of matrix.probes) {
|
||||
const best = matrix.byProbe.get(probeId)?.find((c) => c.rank === 1)
|
||||
for (const [dialerId] of matrix.dialers) {
|
||||
const best = matrix.byDialer.get(dialerId)?.find((c) => c.rank === 1)
|
||||
if (best) ids.push(best.line.id)
|
||||
}
|
||||
return ids.slice(0, MAX_LINES)
|
||||
@@ -115,17 +115,17 @@ export function Compare() {
|
||||
update({ lines: next.join(',') })
|
||||
}
|
||||
|
||||
const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.probe_name)]))
|
||||
const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.dialer_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) }))
|
||||
// 每个 来源 Agent 的最佳线路
|
||||
const winners = matrix.dialers.map(([dialerId, dialerName]) => ({ dialerName, cell: matrix.byDialer.get(dialerId)?.find((c) => c.rank === 1) }))
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<h1>线路对比</h1>
|
||||
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
|
||||
<p className="sub">从每个来源 Agent 出发,对比各目标 Agent 的线路质量。</p>
|
||||
<div className="toolbar">
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => update({ ip_version: v, lines: null })} />
|
||||
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
|
||||
@@ -154,8 +154,8 @@ export function Compare() {
|
||||
<div className="winners">
|
||||
{winners.map((w) =>
|
||||
w.cell ? (
|
||||
<div key={w.probeName} className="card winner">
|
||||
<span className="faint">从 {w.probeName} 看出去最好</span>
|
||||
<div key={w.dialerName} className="card winner">
|
||||
<span className="faint">从 {w.dialerName} 看出去最好</span>
|
||||
<span className="winner-name">
|
||||
<Star size={16} /> {w.cell.line.name}
|
||||
</span>
|
||||
@@ -170,8 +170,8 @@ export function Compare() {
|
||||
<table className="table matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent / Probe</th>
|
||||
{matrix.probes.map(([id, name]) => (
|
||||
<th>来源 \ 目标</th>
|
||||
{matrix.agents.map(([id, name]) => (
|
||||
<th key={id} className="r">
|
||||
{name}
|
||||
</th>
|
||||
@@ -179,16 +179,16 @@ export function Compare() {
|
||||
</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>
|
||||
{matrix.dialers.map(([dialerId, dialerName]) => (
|
||||
<tr key={dialerId}>
|
||||
<td style={{ fontWeight: 600 }}>{dialerName}</td>
|
||||
{matrix.agents.map(([agentId]) => {
|
||||
const cell = matrix.byDialer.get(dialerId)?.find((c) => c.line.agent_id === agentId)
|
||||
if (!cell) return <td key={agentId} 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">
|
||||
<td key={agentId} 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>
|
||||
@@ -202,7 +202,7 @@ export function Compare() {
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="faint" style={{ padding: '4px 12px 8px' }}>
|
||||
★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
|
||||
★ 是这一行(同一个来源 Agent)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Host() {
|
||||
{info.os_name ? <span className="plat"><OsIcon os={info.os} name={info.os_name} />{[info.os_name, info.arch].filter(Boolean).join(' / ')}</span> : null}
|
||||
</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 ? <><Chip>Agent</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>
|
||||
|
||||
@@ -213,8 +213,8 @@ export function Line() {
|
||||
<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}
|
||||
{line.data?.dialer_name ?? '线路'}
|
||||
{line.data ? <span className="muted line-peer-name"> → {line.data.name}</span> : null}
|
||||
</h1>
|
||||
<div className="row line-version-switch">
|
||||
{IP_VERSIONS.map((v) => {
|
||||
@@ -231,15 +231,15 @@ export function Line() {
|
||||
</div>
|
||||
{line.data ? (
|
||||
<div className="row wrap line-route-meta">
|
||||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||
<span className="line-route-status">{line.data.online ? (line.data.probe_connected ? '在线' : '测量未连接') : '离线'}</span>
|
||||
<Dot tone={line.data.online && line.data.dialer_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||
<span className="line-route-status">{line.data.online ? (line.data.dialer_connected ? '在线' : '测量未连接') : '离线'}</span>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="faint">Agent</span>
|
||||
<span className="faint">目标 Agent</span>
|
||||
<NodeTag info={infos.get(line.data.agent_id)} />
|
||||
</span>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="faint">Probe</span>
|
||||
<NodeTag info={infos.get(line.data.probe_id)} />
|
||||
<span className="faint">来源 Agent</span>
|
||||
<NodeTag info={infos.get(line.data.dialer_id)} />
|
||||
</span>
|
||||
{line.data.labels.map((x) => (
|
||||
<Chip key={x}>{x}</Chip>
|
||||
@@ -324,7 +324,7 @@ export function Line() {
|
||||
<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>
|
||||
<div className="line-session-bar" title="会话计数从 Probe 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
|
||||
<div className="line-session-bar" title="会话计数从 来源 Agent 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
|
||||
<strong className="line-session-title">QUIC 会话与传输</strong>
|
||||
{line.data?.quic_health ? (() => {
|
||||
const h = line.data.quic_health!
|
||||
@@ -338,7 +338,7 @@ export function Line() {
|
||||
<span>路径 MTU <b>{fmtNum(h.path_mtu)} B</b></span>
|
||||
</div>
|
||||
</>
|
||||
})() : <span className="line-session-empty">Probe 尚未上报会话数据</span>}
|
||||
})() : <span className="line-session-empty">来源 Agent 尚未上报会话数据</span>}
|
||||
</div>
|
||||
</> : null}
|
||||
{metricGroup !== 'quic' ? <div className="tiles">
|
||||
|
||||
@@ -16,7 +16,7 @@ const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss
|
||||
|
||||
type SortKey = 'name' | 'rtt' | 'loss'
|
||||
|
||||
/** 线路按 Probe 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
|
||||
/** 线路按 来源 Agent 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
|
||||
const CARRIERS = [
|
||||
{ key: 'telecom', name: '电信' },
|
||||
{ key: 'unicom', name: '联通' },
|
||||
@@ -31,7 +31,6 @@ const average = (values: Array<number | null | undefined>) => {
|
||||
return v.length === 0 ? null : v.reduce((a, b) => a + b, 0) / v.length
|
||||
}
|
||||
|
||||
const KIND_NAME = { agent: 'Agent', probe: 'Probe' } as const
|
||||
|
||||
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
|
||||
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
|
||||
@@ -47,7 +46,7 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
|
||||
)
|
||||
}
|
||||
|
||||
/** 一个节点(Agent 或 Probe)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
|
||||
/** 一个节点(Agent)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
|
||||
function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; infos: Map<string, NodeInfoDto> }) {
|
||||
const h = node.host
|
||||
const stale = !node.online || node.stale
|
||||
@@ -58,7 +57,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
||||
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
||||
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
|
||||
<span className="agent-name">{node.name}</span>
|
||||
<span className="faint kind-tag">{KIND_NAME[node.kind]}</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>
|
||||
@@ -130,41 +129,43 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
|
||||
<div className="faint">这台 Agent 没有上报主机状态</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条线路(Probe → Agent):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
|
||||
/** 一条线路(拨号方 → 监听方):近一段时间的 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 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?.state === 'retry_wait'
|
||||
? 'QUIC 拨号失败'
|
||||
: !l.probe_connected
|
||||
? '测量通道未建立'
|
||||
: l.quic_health && !l.quic_health.connected
|
||||
? 'QUIC 正在连接'
|
||||
: null
|
||||
: 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.probe_name, probeInfo?.tag || probeInfo?.version].filter(Boolean).join(' · ')}>
|
||||
{probeInfo?.country ? <Flag code={probeInfo.country} /> : null}
|
||||
<span className="name">{l.probe_name}</span>
|
||||
{probeInfo?.isp_key ? <IspIcon ispKey={probeInfo.isp_key} name={probeInfo.isp_name} /> : null}
|
||||
<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">→</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' ? `Probe 正在等待退避重试${l.quic_health.retry_in_ms ? `(约 ${Math.ceil(l.quic_health.retry_in_ms / 1000)} 秒)` : ''}。` : !l.probe_connected && l.online ? 'Agent 在线,但 Probe 尚未建立测量会话。' : undefined}
|
||||
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>
|
||||
@@ -196,13 +197,12 @@ export function Overview() {
|
||||
)
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 10_000)
|
||||
|
||||
// 节点卡片:先 Agent 后 Probe,各自在线的在前、再按名字;Agent 的标签来自它的线路
|
||||
// 节点卡片:在线节点在前、再按名字;Agent 的标签来自它的线路
|
||||
const nodes = useMemo(() => {
|
||||
const labels = new Map<string, string[]>()
|
||||
for (const l of data?.lines ?? []) labels.set(l.agent_id, l.labels)
|
||||
const rank = (n: HostItem) => (n.kind === 'agent' ? 0 : 1)
|
||||
return [...hosts.values()]
|
||||
.sort((a, b) => rank(a) - rank(b) || Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
|
||||
.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 版本过滤,再排序
|
||||
@@ -211,7 +211,7 @@ export function Overview() {
|
||||
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.probe_name.localeCompare(b.probe_name)
|
||||
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]
|
||||
@@ -221,15 +221,15 @@ export function Overview() {
|
||||
return x - y
|
||||
})
|
||||
}, [data, sort, ipVersion])
|
||||
// 按 Probe 的运营商分组(固定顺序,没有线路的组不显示)
|
||||
// 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示)
|
||||
const groups = useMemo(
|
||||
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.probe_id)) === c.key) })).filter((g) => g.lines.length > 0),
|
||||
() => 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 = (kind: 'agent' | 'probe') => {
|
||||
const all = nodes.filter((n) => n.node.kind === kind)
|
||||
const count = () => {
|
||||
const all = nodes
|
||||
return `${all.filter((n) => n.node.online).length} / ${all.length}`
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export function Overview() {
|
||||
<section className="hero">
|
||||
<h1>总览</h1>
|
||||
<p className="sub">
|
||||
{hosts.size > 0 || data ? `Agent ${count('agent')} 在线 · Probe ${count('probe')} 在线` : '加载中…'}
|
||||
{hosts.size > 0 || data ? `Agent ${count()} 在线` : '加载中…'}
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
</p>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user