From efb50d0efece13c22855b698195e45f7bcfd020c Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 10 Oct 2026 14:17:19 +0800 Subject: [PATCH] feat(admin): show Probe status and retry links --- web/admin/src/App.tsx | 2 + web/admin/src/pages/Agents.tsx | 14 +--- web/admin/src/pages/ProbeDetail.tsx | 118 ++++++++++++++++++++++++++++ web/admin/src/pages/Probes.tsx | 27 ++++--- web/admin/vite.config.ts | 2 + web/shared/nodes.tsx | 20 ++++- web/shared/theme/base.css | 10 +++ 7 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 web/admin/src/pages/ProbeDetail.tsx diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index 5689a6f..5dddf6d 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index c35e465..ddc64ef 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -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 = { - active: { label: '已启用', tone: 'ok' }, - pending: { label: '待审批', tone: 'warn' }, - disabled: { label: '已停用', tone: 'bad' }, -} - -export function AgentStatus({ agent }: { agent: Pick }) { - const s = STATUS[agent.status] ?? { label: agent.status, tone: 'warn' as const } +export function AgentStatus({ agent }: { agent: Pick }) { return ( - {s.label} + + {agent.status === 'pending' ? 待审批 : null} ) } diff --git a/web/admin/src/pages/ProbeDetail.tsx b/web/admin/src/pages/ProbeDetail.tsx new file mode 100644 index 0000000..44761df --- /dev/null +++ b/web/admin/src/pages/ProbeDetail.tsx @@ -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(`/api/admin/probes/${encodeURIComponent(id)}`), [id], 5000) + const infos = useNodeInfos() + const p = probe.data + + return ( + <> + + + Probe + +
+

{p?.name ?? id}

+ {p ? ( + + + + + ) : null} + + {probe.loading && !p ? : null} + {p ? : null} + +
+ + + + {p ? ( +
+
+
+
Probe 状态
+
+
运行状态
+
+
面板连接
+
{p.online ? '已连接' : '未连接'}
+
测量连接
+
{p.connections.length ? `连接 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '当前无活动 Agent 会话'}
+
IPv6 诊断
+
{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? '状态未知' : '等待 Probe 上报'}
+ {p.ipv6 ? <> +
检测网络
+
{p.ipv6.network_scope === 'host' ? '宿主机' : p.ipv6.network_scope === 'container' ? '容器网络' : '未知'}
+
IPv6 默认路由
+
{p.ipv6.has_default_route == null ? '未知' : p.ipv6.has_default_route ? '存在' : '缺失'}
+ : null} +
Probe ID
+
{p.id}
+
创建时间
+
{fmtTime(p.created_at)}
+ {p.added_by ? <>
添加者
{p.added_by}
: null} + {p.revoked_at ? <>
吊销时间
{fmtTime(p.revoked_at)}
: null} +
+

+ {p.runtime_status === 'probe_paused' + ? '没有已启用的 Agent 目标;在「Probe → 线路」里开启线路后会开始探测。' + : p.online && p.connections.length === 0 + ? 'Probe 已连接面板,但当前没有建立 Agent 测量会话。' + : !p.online + ? `Probe 当前未连接面板${p.revoked ? ',且已吊销' : ''}。` + : 'Probe 正在连接已启用的 Agent,并通过 QUIC Datagram 进行逐包探测。'} +

+ + 查看主机状态 + +
+ +
+
Agent 会话({p.connections.length})
+ {p.connections.length === 0 ? ( + {p.online ? 'Probe 在线,但暂时没有活动的 Agent 会话。' : 'Probe 离线时没有活动会话。'} + ) : ( + + + + + + + + + + + + + {p.connections.map((c) => ( + + + + + + + + + + ))} + +
Agent状态IP 版本版本来源地址连接时间 +
{c.agent_name}{c.agent_online ? '运行中' : 'Agent 离线'}{c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}{c.version ?? '—'}{c.remote ?? '—'}{fmtTime(c.connected_since)}({fmtAgo(c.connected_since)})查看线路
+ )} +
+
+
+ ) : null} + + ) +} diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index 8a56305..9acf55a 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -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('/api/admin/links'), [], 8000) - const probeState = useQuery(() => api.get>('/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 - 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 ( @@ -86,7 +85,13 @@ function LinkMatrix() { ) : null} + {link.linked && !link.connected ? ( + + ) : null} + {!link.connected && link.linked && link.connection_reason ?
{link.connection_reason}
: null} ) })} @@ -254,14 +259,16 @@ export function Probes() { {list.map((p) => (
- {p.name} + + {p.name} + {p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'} {p.revoked ? ( 已吊销 ) : ( <> - {p.online ? '已连面板' : '未连面板'} + 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'} )} diff --git a/web/admin/vite.config.ts b/web/admin/vite.config.ts index d12bc76..345aea6 100644 --- a/web/admin/vite.config.ts +++ b/web/admin/vite.config.ts @@ -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: '' }, diff --git a/web/shared/nodes.tsx b/web/shared/nodes.tsx index c57e87d..b051e19 100644 --- a/web/shared/nodes.tsx +++ b/web/shared/nodes.tsx @@ -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) { ) } + +const RUNTIME_STATUS: Record = { + 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 {value.label} +} + +export const runtimeStatusLabel = (status: NodeRuntimeStatus) => RUNTIME_STATUS[status]?.label ?? status diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 1c51d2f..3b69c20 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -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;