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
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
bac1c27e72
commit
88b1a0a9bb
36 files changed
+750
-185
No files matched your search
@@ -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} />
|
||||
改名
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user