feat: probe host stats, node cards with a separate lines panel grouped by carrier, Windows host sampling, manual carrier override [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-08 21:56:19 +08:00
1 parent bac1c27e72
commit 88b1a0a9bb
36 files changed
+750 -185

No files matched your search

+26
View File
@@ -191,6 +191,15 @@ function NameDialog({ title, initial, onClose, onSubmit }: { title: string; init
)
}
const ISP_CHOICES = [
{ value: 'auto', label: '运营商:自动识别' },
{ value: 'telecom', label: '中国电信' },
{ value: 'unicom', label: '中国联通' },
{ value: 'mobile', label: '中国移动' },
{ value: 'broadnet', label: '中国广电' },
{ value: 'other', label: '其他' },
]
type Pending = { kind: 'revoke' | 'delete'; probe: ProbeDto }
export function Probes() {
@@ -200,6 +209,8 @@ export function Probes() {
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>()
const [pending, setPending] = useState<Pending>()
// 刚改过的运营商(公开的节点信息每分钟才刷新一次,先按这里显示)
const [isp, setIsp] = useState<Record<string, string>>({})
const list = probes.data?.items ?? []
@@ -246,6 +257,21 @@ export function Probes() {
)}
<span className="mono faint">{p.id}</span>
<span className="right row wrap">
<select
className="input small"
title="展示页按电信 / 联通 / 移动给线路分组,自动识别不准时在这里手动指定"
value={isp[p.id] ?? (infos.get(p.id)?.isp_manual ? infos.get(p.id)?.isp_key ?? 'auto' : 'auto')}
onChange={async (e) => {
const value = e.target.value
if (await run(() => api.patch(`/api/admin/probes/${p.id}`, { isp: value }), '已保存')) setIsp((m) => ({ ...m, [p.id]: value }))
}}
>
{ISP_CHOICES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
<button type="button" className="btn small" onClick={() => setRenaming(p)}>
<Pencil size={14} />
改名
+66
View File
@@ -1257,3 +1257,69 @@ input {
.agent-card:has(.pop.open) {
z-index: 30;
}
/* ── 总览:节点卡片下面单独的「线路」面板 ── */
.agent-name + .kind-tag {
font-size: 12px;
}
.lines-panel {
margin-top: 24px;
}
.cards + .lines-panel {
margin-top: 16px;
}
.lines-panel .lines-toolbar {
margin-top: 0;
}
.lines-panel .agent-head .faint {
font-size: 12px;
}
.line-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
}
.line-card {
padding: 10px 12px;
}
.line-card .line-name {
flex-wrap: wrap;
row-gap: 2px;
}
.line-card .line-name .arrow {
color: var(--app-ink-faint);
}
/* 线路面板:按三网分组 */
.carrier-group + .carrier-group {
margin-top: 18px;
}
.carrier-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.carrier-head .carrier-sum {
display: flex;
gap: 6px;
align-items: baseline;
font-size: 13px;
}
.carrier-head .isp {
width: 20px;
height: 20px;
flex: none;
}
+7 -6
View File
@@ -20,7 +20,8 @@ export interface Host {
}
export interface HostItem {
agent_id: string
id: string
kind: 'agent' | 'probe'
name: string
online: boolean
stale: boolean
@@ -29,7 +30,7 @@ export interface HostItem {
}
/**
* Agent 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* Agent 和 Probe 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* 连接断开时浏览器会自动重连;重连之后服务端会先推一份当前快照。
*/
export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
@@ -40,18 +41,18 @@ export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
let alive = true
api
.get<Page<HostItem>>('/api/public/hosts')
.then((page) => alive && setHosts(new Map(page.items.map((i) => [i.agent_id, i]))))
.then((page) => alive && setHosts(new Map(page.items.map((i) => [i.id, i]))))
.catch(() => undefined)
const source = openLive('/api/public/live?topics=hosts,nodes')
source.onopen = () => setLive(true)
source.onerror = () => setLive(false)
source.addEventListener('host', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data) as { agent_id: string; online: boolean; host: Host }
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; kind: 'agent' | 'probe'; online: boolean; host: Host }
setHosts((previous) => {
const next = new Map(previous)
const old = previous.get(data.agent_id)
next.set(data.agent_id, { agent_id: data.agent_id, name: old?.name ?? data.agent_id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
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 })
return next
})
})
+1 -1
View File
@@ -103,7 +103,7 @@ 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/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)),
() => (agentId && range ? api.get<HostSeries>(`/api/public/hosts/${agentId}`, { range }) : Promise.resolve(undefined)),
[agentId, range],
30_000,
)
+143 -86
View File
@@ -16,14 +16,22 @@ const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50',
type SortKey = 'name' | 'rtt' | 'loss'
interface AgentCard {
id: string
name: string
labels: string[]
online: boolean
lines: OverviewLineDto[]
/** 线路按 Probe 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
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
}
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'
@@ -39,18 +47,20 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
)
}
function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const h = host?.host ?? null
const stale = !agent.online || !host || host.stale
const info = infos.get(agent.id)
/** 一个节点(Agent 或 Probe)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
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 (
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
<div className={`card agent-card ${node.online ? '' : 'offline'}`}>
<div className="agent-head">
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
{info?.country ? <Flag code={info.country} /> : <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}
{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>
<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">
@@ -82,34 +92,14 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
) : null}
</div>
) : null}
{agent.labels.length > 0 ? (
{labels.length > 0 ? (
<div className="line-meta">
{agent.labels.map((x) => (
{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">
<div className="line-top">
<span className="line-name" title={[l.probe_name, infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version].filter(Boolean).join(' · ')}>
<span className="arrow">→</span>
{infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
<span className="name">{l.probe_name}</span>
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
</span>
{!l.probe_connected && l.online ? <span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 和 UDP 通道,线路或运营商对它们的处理可能和 ping 不一样。"><Chip tone="warn">测量通道未建立</Chip></span> : <span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>}
</div>
<LineStrips lineId={l.id} range={range} m={m} />
</Link>
)
})}
</div>
<div className={`host ${stale ? 'stale' : ''}`}>
{h ? (
<>
@@ -126,7 +116,7 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
<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)}
↑ {fmtSpeed(node.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(node.online ? h.net_rx_bps : null)}
</span>
</div>
<div className="stat">
@@ -140,15 +130,43 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem
</div>
</>
) : (
<div className="faint">这台 Agent 没有上报主机状态</div>
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
)}
</div>
</div>
)
}
/** 一条线路(Probe → Agent):近一段时间的 UDP 丢包和延迟走势、TCP 的 RTT 和重传。 */
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)
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="arrow">→</span>
{agentInfo?.country ? <Flag code={agentInfo.country} /> : null}
<span className="name">{l.name}</span>
</span>
{!l.probe_connected && l.online ? (
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 和 UDP 通道,线路或运营商对它们的处理可能和 ping 不一样。">
<Chip tone="warn">测量通道未建立</Chip>
</span>
) : (
<span className="faint line-range">{OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
)}
</div>
<LineStrips lineId={l.id} range={range} m={l.summary.metrics} />
</Link>
)
}
export function Overview() {
const [probe, setProbe] = useState<string>('')
const [carrier, setCarrier] = useState<'' | CarrierKey>('')
const [sort, setSort] = useState<SortKey>('name')
const { hosts, live } = useHosts()
const infos = useNodeInfos()
@@ -169,46 +187,72 @@ export function Overview() {
)
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 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 (ipVersionOf(l.ip_version) !== ipVersion) continue
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) => {
// 节点卡片:先 Agent 后 Probe,各自在线的在前、再按名字;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))
.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
if (sort === 'name') return a.name.localeCompare(b.name)
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
const x = best(a, key, Math.min)
const y = best(b, key, Math.min)
if (x === null && y === null) return a.name.localeCompare(b.name)
if (x === null) return 1
if (y === null) return -1
const byName = () => a.name.localeCompare(b.name) || a.probe_name.localeCompare(b.probe_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, probe, sort, ipVersion])
}, [data, sort, ipVersion])
// 按 Probe 的运营商分组(固定顺序,没有线路的组不显示)
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),
[lines, infos],
)
const shown = groups.filter((g) => !carrier || g.key === carrier)
const online = cards.filter((c) => c.online).length
const count = (kind: 'agent' | 'probe') => {
const all = nodes.filter((n) => n.node.kind === kind)
return `${all.filter((n) => n.node.online).length} / ${all.length}`
}
return (
<main className="page">
<section className="hero">
<h1>总览</h1>
<p className="sub">
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
{hosts.size > 0 || data ? `Agent ${count('agent')} 在线 · Probe ${count('probe')} 在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</p>
<div className="toolbar">
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setProbe(''); setParams(next, { replace: true }) }} />
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
</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
@@ -220,27 +264,40 @@ export function Overview() {
value={sort}
onChange={setSort}
/>
{loading && !data ? <Spinner /> : null}
</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>
<PageError error={error} />
{data ? (
cards.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<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>
) : (
<div className="cards">
{cards.map((c) => (
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} infos={infos} />
))}
</div>
)
) : null}
</main>
)
}
+10 -6
View File
@@ -164,7 +164,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Latest host resource usage of every visible agent (no hardware model, no addresses) */
/** Latest host resource usage of every visible agent and probe (no hardware model, no addresses) */
get: operations["hosts_list"];
put?: never;
post?: never;
@@ -174,14 +174,14 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/public/agents/{agent_id}/host": {
"/api/public/hosts/{node_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Host resource usage history of one agent (columnar, averaged per step) */
/** Host resource usage history of one agent or probe (columnar, averaged per step) */
get: operations["hosts_history"];
put?: never;
post?: never;
@@ -1592,7 +1592,8 @@ export interface components {
at: unknown;
};
HostItemDto: {
agent_id: string;
id: string;
kind: string;
name: string;
online: boolean;
stale: boolean;
@@ -1601,7 +1602,7 @@ export interface components {
host: null | components["schemas"]["HostDto"];
};
HostSeriesDto: {
agent_id: string;
id: string;
/** Format: date-time */
from: unknown;
/** Format: date-time */
@@ -1766,6 +1767,8 @@ export interface components {
isp_name: null | string;
version?: null | string;
tag?: null | string;
/** @default false */
isp_manual: boolean;
};
NodeOwnerDto: {
credential_id: string;
@@ -1939,6 +1942,7 @@ export interface components {
};
PatchProbeRequest: {
name: null | string;
isp?: null | string;
};
PatchTaskRequest: {
params: null | Record<string, never>;
@@ -2832,7 +2836,7 @@ export interface operations {
};
header?: never;
path: {
agent_id: string;
node_id: string;
};
cookie?: never;
};