feat(admin): show Probe status and retry links
This commit is contained in:
1 parent
97ab3cc740
commit
efb50d0efe
7 files changed
+172
-21
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user