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:
justme committed 2026-10-21 11:55:50 +08:00
1 parent 016dbc2bbf
commit 15f0023614
32 files changed
+937 -1093

No files matched your search

+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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
})
})
+27 -27
View File
@@ -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>
+1 -1
View File
@@ -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>
+9 -9
View File
@@ -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">
+28 -28
View File
@@ -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>