feat(admin): show Probe status and retry links

This commit is contained in:
justme committed 2026-10-10 14:17:19 +08:00
1 parent 97ab3cc740
commit efb50d0efe
7 files changed
+172 -21

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import { Setup } from './Setup'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
import { ProbeDetail } from './pages/ProbeDetail'
import { System } from './pages/System'
import { Tasks } from './pages/Tasks'
import { Tests } from './pages/Tests'
@@ -286,6 +287,7 @@ export function App() {
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<AgentDetail />} />
<Route path="/probes" element={<Probes />} />
<Route path="/probes/:id" element={<ProbeDetail />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/tests" element={<Tests />} />
<Route path="/system" element={<System />} />
+4 -10
View File
@@ -5,23 +5,17 @@ import { api } from '@shared/api/client'
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { NodeTag, RuntimeStatusChip, useNodeInfos } from '@shared/nodes'
import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
import { OwnerPicker } from './OwnerPicker'
import { JoinTokenCreated } from './JoinTokenCreated'
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
active: { label: '已启用', tone: 'ok' },
pending: { label: '待审批', tone: 'warn' },
disabled: { label: '已停用', tone: 'bad' },
}
export function AgentStatus({ agent }: { agent: Pick<AgentDto, 'status' | 'online'> }) {
const s = STATUS[agent.status] ?? { label: agent.status, tone: 'warn' as const }
export function AgentStatus({ agent }: { agent: Pick<AgentDto, 'status' | 'online' | 'runtime_status'> }) {
return (
<span className="row" style={{ gap: 8 }}>
<Dot tone={agent.online ? 'ok' : 'bad'} />
<Chip tone={s.tone}>{s.label}</Chip>
<RuntimeStatusChip status={agent.runtime_status} />
{agent.status === 'pending' ? <Chip tone="warn">待审批</Chip> : null}
</span>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { ChevronLeft } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { ProbeDto } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, RuntimeStatusChip, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions'
import { Chip, Dot, Empty, ErrorNotice, Spinner } from '@shared/ui'
export function ProbeDetail() {
const { id = '' } = useParams()
const probe = useQuery(() => api.get<ProbeDto>(`/api/admin/probes/${encodeURIComponent(id)}`), [id], 5000)
const infos = useNodeInfos()
const p = probe.data
return (
<>
<Link to="/probes" className="row muted" style={{ marginBottom: 8 }}>
<ChevronLeft size={16} />
Probe
</Link>
<header>
<h1>{p?.name ?? id}</h1>
{p ? (
<span className="row">
<Dot tone={p.online ? 'ok' : 'bad'} />
<RuntimeStatusChip status={p.runtime_status} />
</span>
) : null}
<span className="right row">
{probe.loading && !p ? <Spinner /> : null}
{p ? <NodeTag info={infos.get(p.id)} /> : null}
</span>
</header>
<ErrorNotice error={probe.error} />
{p ? (
<div className="stack">
<div className="grid cols-2">
<section className="card">
<div className="card-title">Probe 状态</div>
<dl className="kv">
<dt>运行状态</dt>
<dd><RuntimeStatusChip status={p.runtime_status} /></dd>
<dt>面板连接</dt>
<dd><Chip tone={p.online ? 'ok' : 'bad'}>{p.online ? '已连接' : '未连接'}</Chip></dd>
<dt>测量连接</dt>
<dd>{p.connections.length ? `连接 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '当前无活动 Agent 会话'}</dd>
<dt>IPv6 诊断</dt>
<dd>{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? '状态未知' : '等待 Probe 上报'}</dd>
{p.ipv6 ? <>
<dt>检测网络</dt>
<dd>{p.ipv6.network_scope === 'host' ? '宿主机' : p.ipv6.network_scope === 'container' ? '容器网络' : '未知'}</dd>
<dt>IPv6 默认路由</dt>
<dd>{p.ipv6.has_default_route == null ? '未知' : p.ipv6.has_default_route ? '存在' : '缺失'}</dd>
</> : null}
<dt>Probe ID</dt>
<dd className="mono">{p.id}</dd>
<dt>创建时间</dt>
<dd>{fmtTime(p.created_at)}</dd>
{p.added_by ? <><dt>添加者</dt><dd>{p.added_by}</dd></> : null}
{p.revoked_at ? <><dt>吊销时间</dt><dd>{fmtTime(p.revoked_at)}</dd></> : null}
</dl>
<p className="faint" style={{ marginTop: 12 }}>
{p.runtime_status === 'probe_paused'
? '没有已启用的 Agent 目标;在「Probe → 线路」里开启线路后会开始探测。'
: p.online && p.connections.length === 0
? 'Probe 已连接面板,但当前没有建立 Agent 测量会话。'
: !p.online
? `Probe 当前未连接面板${p.revoked ? ',且已吊销' : ''}。`
: 'Probe 正在连接已启用的 Agent,并通过 QUIC Datagram 进行逐包探测。'}
</p>
<a className="btn small" href={`/host/${encodeURIComponent(p.id)}`}>
查看主机状态
</a>
</section>
<section className="card">
<div className="card-title">Agent 会话({p.connections.length})</div>
{p.connections.length === 0 ? (
<Empty>{p.online ? 'Probe 在线,但暂时没有活动的 Agent 会话。' : 'Probe 离线时没有活动会话。'}</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>Agent</th>
<th>状态</th>
<th>IP 版本</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
<th />
</tr>
</thead>
<tbody>
{p.connections.map((c) => (
<tr key={`${c.agent_id}~${c.ip_version}`}>
<td><Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>{c.agent_name}</Link></td>
<td><Chip tone={c.agent_online ? 'ok' : 'bad'}>{c.agent_online ? '运行中' : 'Agent 离线'}</Chip></td>
<td><Chip>{c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip></td>
<td>{c.version ?? '—'}</td>
<td className="mono">{c.remote ?? '—'}</td>
<td className="muted">{fmtTime(c.connected_since)}({fmtAgo(c.connected_since)})</td>
<td className="r"><a className="btn small" href={`/line/${encodeURIComponent(`${c.agent_id}~${p.id}${c.ip_version === 'ipv6' ? '~ipv6' : ''}`)}`}>查看线路</a></td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
</div>
) : null}
</>
)
}
+17 -10
View File
@@ -5,7 +5,7 @@ import { api } from '@shared/api/client'
import type { CreatedJoinTokenDto, LinkDto, LinkMatrixDto, Page, ProbeDto } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { NodeTag, RuntimeStatusChip, runtimeStatusLabel, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
import { JoinTokenCreated } from './JoinTokenCreated'
@@ -17,10 +17,8 @@ import { OwnerPicker } from './OwnerPicker'
*/
function LinkMatrix() {
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
const probeState = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
const { busy, run } = useAction()
const items = matrix.data?.items ?? []
const onlineByProbe = new Map((probeState.data?.items ?? []).map((probe) => [probe.id, probe.online]))
const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()]
const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()]
const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId)
@@ -28,6 +26,10 @@ function LinkMatrix() {
await run(() => api.put(`/api/admin/links/${link.probe_id}/${link.agent_id}`, { enabled }))
matrix.reload()
}
const retry = async (link: LinkDto) => {
await run(() => api.post(`/api/admin/links/${link.probe_id}/${link.agent_id}/retry`), '已要求 Probe 立即重试;若 Probe 离线,恢复后会自动重连')
matrix.reload()
}
const setAuto = async (on: boolean) => {
await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接')
matrix.reload()
@@ -60,16 +62,13 @@ function LinkMatrix() {
{agents.map(([agentId]) => {
const link = cell(probeId, agentId)
if (!link) return <td key={agentId} />
const probeOnline = onlineByProbe.get(probeId)
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
const text = link.connected ? '已连接' : !link.linked ? '不连' : probeOnline === false ? 'Probe 离线' : '待连接'
const text = link.connected ? '已连接' : !link.linked ? '未连接' : link.connection_state === 'retry_wait' ? '重试等待' : link.connection_state === 'waiting_report' ? '等待状态' : link.connection_state === 'control_disconnected' ? '控制连接失败' : link.connection_state === 'quic_connecting' ? 'QUIC 握手中' : link.probe_runtime_status !== 'running' ? runtimeStatusLabel(link.probe_runtime_status) : link.agent_runtime_status !== 'running' ? runtimeStatusLabel(link.agent_runtime_status) : '连接中'
const statusHint = link.connected
? 'Probe 已和 Agent 建立测量会话'
: !link.linked
? '这条线路已关闭'
: probeOnline === false
? 'Probe 当前没有连接到面板;Probe 上线后才会尝试连接 Agent'
: '线路已启用,等待 Probe 与 Agent 建立测量会话'
: [link.connection_reason, link.last_error, link.retry_in_ms ? `约 ${Math.ceil(link.retry_in_ms / 1000)} 秒后自动重试` : null].filter(Boolean).join(';') || `Probe:${runtimeStatusLabel(link.probe_runtime_status)};Agent:${runtimeStatusLabel(link.agent_runtime_status)}`
return (
<td key={agentId}>
<span className="row" style={{ gap: 6 }}>
@@ -86,7 +85,13 @@ function LinkMatrix() {
</button>
</>
) : null}
{link.linked && !link.connected ? (
<button type="button" className="btn small" disabled={busy} title={statusHint} onClick={() => retry(link)}>
立即重试
</button>
) : null}
</span>
{!link.connected && link.linked && link.connection_reason ? <div className="faint" style={{ marginTop: 4, maxWidth: 320 }} title={link.last_error ?? undefined}>{link.connection_reason}</div> : null}
</td>
)
})}
@@ -254,14 +259,16 @@ export function Probes() {
{list.map((p) => (
<div key={p.id} className="card">
<div className="row wrap">
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
<Link to={`/probes/${encodeURIComponent(p.id)}`} style={{ fontSize: 18, fontWeight: 700, color: 'inherit', textDecoration: 'none' }}>
{p.name}
</Link>
<NodeTag info={infos.get(p.id)} />
<Chip tone={p.ipv6?.state === 'ready' ? 'ok' : 'warn'}>{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'}</Chip>
{p.revoked ? (
<Chip tone="bad">已吊销</Chip>
) : (
<>
<Chip tone={p.online ? 'ok' : ''}>{p.online ? '已连面板' : '未连面板'}</Chip>
<RuntimeStatusChip status={p.runtime_status} />
<Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'}</Chip>
</>
)}
+2
View File
@@ -17,6 +17,8 @@ export default defineConfig(({ mode }) => {
// 5174 常被 Docker Desktop 占用,管理端用 5175;端口被占时直接报错,不悄悄换到别的端口
port: 5175,
strictPort: true,
// WSL 在 /mnt/c 上收不到可靠的 inotify 事件,需要轮询以保证 Windows 编辑能触发 HMR。
watch: process.env.WSL_DISTRO_NAME ? { usePolling: true, interval: 500 } : undefined,
fs: { allow: ['..'] },
proxy: {
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
+19 -1
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from './api/client'
import type { NodeInfoDto, Page } from './api/types'
import type { NodeInfoDto, NodeRuntimeStatus, Page } from './api/types'
import { useQuery } from './hooks'
import { OsIcon } from './osIcon'
import { Chip } from './ui'
import mobileIcon from './assets/isp/mobile.svg?url'
import telecomIcon from './assets/isp/telecom.svg?url'
import unicomIcon from './assets/isp/unicom.svg?url'
@@ -102,3 +103,20 @@ export function NodeTag({ info, geoOnly }: TagProps) {
</span>
)
}
const RUNTIME_STATUS: Record<NodeRuntimeStatus, { label: string; tone: 'ok' | 'warn' | 'bad' | 'accent' | '' }> = {
initializing: { label: '初始化中', tone: 'warn' },
running: { label: '运行中', tone: 'ok' },
probe_paused: { label: '暂停探测', tone: 'warn' },
maintenance: { label: '维护中', tone: 'warn' },
stopping: { label: '停止中', tone: 'warn' },
offline: { label: '失联', tone: 'bad' },
updating: { label: '更新中', tone: 'accent' },
}
export function RuntimeStatusChip({ status }: { status: NodeRuntimeStatus }) {
const value = RUNTIME_STATUS[status] ?? { label: status, tone: '' as const }
return <Chip tone={value.tone}>{value.label}</Chip>
}
export const runtimeStatusLabel = (status: NodeRuntimeStatus) => RUNTIME_STATUS[status]?.label ?? status
+10
View File
@@ -988,6 +988,16 @@ pre.code {
}
/* ── 节点的国旗、运营商图标、系统和架构 ── */
.flag {
display: inline-block;
flex: none;
width: 18px;
max-width: 18px;
height: 13.5px;
border-radius: 2px;
object-fit: cover;
}
.node-tag {
display: inline-flex;
align-items: center;