import { ChevronLeft, FlaskConical, Pencil, Unplug } from 'lucide-react' import { useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { AgentDto, EventDto, Page, ProbeDto } from '@shared/api/types' import { fmtAgo, fmtNum, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' import { AgentStatus } from './Agents' function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) { const { busy, run } = useAction() const [name, setName] = useState(agent.name) const [labels, setLabels] = useState(agent.labels.join(', ')) const [host, setHost] = useState(agent.public_host) const [visible, setVisible] = useState(agent.public_visible) return ( } >
setName(e.target.value)} />
setHost(e.target.value)} />
setLabels(e.target.value)} />
) } type Action = 'approve' | 'disable' | 'enable' | 'takeover' | 'delete' const ACTIONS: Record = { approve: { label: '审批通过', text: '审批之后,这个 Agent 会被启用并加入所有 Probe 的列表。' }, enable: { label: '启用', text: '启用之后,这个 Agent 会重新加入 Probe 的列表。' }, disable: { label: '停用', text: '停用之后,Agent 的连接会被断开,它也会从 Probe 的列表里消失。数据保留。', danger: true }, takeover: { label: '接管同名节点', text: '用这个待审批的节点替换同名的旧节点,并保留旧节点的历史数据。', danger: true }, delete: { label: '删除', text: '删除 Agent。已经入库的数据按保留策略自然过期。', danger: true }, } export function AgentDetail() { const { id = '' } = useParams() const navigate = useNavigate() const agent = useQuery(() => api.get(`/api/admin/agents/${id}`), [id], 5000) const probes = useQuery(() => api.get>('/api/admin/probes'), []) const events = useQuery(() => api.get>('/api/admin/events', { agent_id: id, limit: 20 }), [id], 15_000) const [editing, setEditing] = useState(false) const [pending, setPending] = useState() const { run } = useAction() const a = agent.data const probeName = (probeId: string) => probes.data?.items.find((p) => p.id === probeId)?.name ?? probeId const act = async (action: Action) => { if (action === 'delete') { const ok = await run(() => api.del(`/api/admin/agents/${id}`), '已删除') if (ok) navigate('/agents') } else { await run(() => api.post(`/api/admin/agents/${id}/${action}`), '已完成') agent.reload() } } return ( <> Agent

{a?.name ?? id}

{a ? : null} {agent.loading && !a ? : null} {a ? ( <> {a.status === 'pending' ? ( <> ) : a.status === 'disabled' ? ( ) : ( )} ) : null}
{a ? (
连着的 Probe({a.probes.length})
{a.probes.length === 0 ? ( 现在没有 Probe 连着这个 Agent ) : ( {a.probes.map((p) => ( ))}
Probe 版本 来源地址 连接时间
{probeName(p.probe_id)}
{p.probe_id}
{p.version ?? '—'} {p.remote ?? '—'} {fmtTime(p.connected_since)}({fmtAgo(p.connected_since)}) 测试
)}
基本信息
ID
{a.id}
公开地址
{a.public_host || '—'}
控制端口
{a.probe_tcp_port}/tcp
数据端口
{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}
版本
{a.agent_version ?? '—'}
展示页可见
{a.public_visible ? '是' : '否'}
注册时间
{fmtTime(a.created_at)}
最后在线
{fmtTime(a.last_seen_at)}
标签
{a.labels.length ? a.labels.map((x) => {x}) : —}
支持的采集
{a.kinds.map((x) => ( {x} ))}
运行状态
任务同步
期望版本 {a.task_version},已应用 {a.applied_task_version} {a.task_version !== a.applied_task_version ? 未同步 : 已同步}
{a.rejected.length ? ( <>
被拒绝的任务
{a.rejected.map((r) => (
{r.id} {r.reason}
))}
) : null}
NAT 端点变化
{a.endpoint_changes} 次
{a.heartbeat ? ( <>
心跳
{fmtAgo(a.heartbeat.at)}
收发包速率
入 {fmtNum(a.heartbeat.pps_in)} pps / 出 {fmtNum(a.heartbeat.pps_out)} pps
丢弃(无效 / 限速)
{fmtNum(a.heartbeat.dropped_invalid)} / {fmtNum(a.heartbeat.dropped_rate_limited)}
待上报队列
{fmtNum(a.heartbeat.queue_bytes / 1024)} KiB
) : null} {a.local_limits ? ( <>
本地上限
{fmtNum(a.local_limits.max_total_mbps)} Mbit/s,{fmtNum(a.local_limits.max_total_pps)} pps,{a.local_limits.max_concurrent_tests} 个并发测试
) : null}
最近事件
{(events.data?.items ?? []).length === 0 ? ( 没有事件 ) : ( {events.data?.items.map((e) => ( ))}
{fmtTime(e.at)} {e.kind} {e.detail ?? ''}
)}
) : null} {editing && a ? setEditing(false)} onSaved={agent.reload} /> : null} {pending ? ( setPending(undefined)} onConfirm={() => act(pending)}> {ACTIONS[pending].text} ) : null} ) }