Files
MoShiro/web/admin/src/pages/AgentDetail.tsx
T
2026-10-04 21:46:39 +08:00

324 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<Dialog
title="修改 Agent"
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy}
onClick={async () => {
const ok = await run(
() => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host, public_visible: visible }),
'已保存',
)
if (ok) {
onSaved()
onClose()
}
}}
>
保存
</button>
</>
}
>
<div className="form-grid">
<div className="wide">
<Field label="名称" hint="字母、数字、点、下划线和连字符">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
</div>
<div className="wide">
<Field label="公开地址" hint="Probe 用它连接这个 Agent:域名或 IP">
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} />
</Field>
</div>
<div className="wide">
<Field label="标签" hint="逗号分隔">
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} />
</Field>
</div>
<div className="wide">
<label className="switch">
<input type="checkbox" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
在展示页对匿名访客可见
</label>
</div>
</div>
</Dialog>
)
}
type Action = 'approve' | 'disable' | 'enable' | 'takeover' | 'delete'
const ACTIONS: Record<Action, { label: string; text: string; danger?: boolean }> = {
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<AgentDto>(`/api/admin/agents/${id}`), [id], 5000)
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [])
const events = useQuery(() => api.get<Page<EventDto>>('/api/admin/events', { agent_id: id, limit: 20 }), [id], 15_000)
const [editing, setEditing] = useState(false)
const [pending, setPending] = useState<Action>()
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 (
<>
<Link to="/agents" className="row muted" style={{ marginBottom: 8 }}>
<ChevronLeft size={16} />
Agent
</Link>
<header>
<h1>{a?.name ?? id}</h1>
{a ? <AgentStatus agent={a} /> : null}
<span className="right row wrap">
{agent.loading && !a ? <Spinner /> : null}
{a ? (
<>
<button type="button" className="btn" onClick={() => setEditing(true)}>
<Pencil size={16} />
修改
</button>
{a.status === 'pending' ? (
<>
<button type="button" className="btn primary" onClick={() => setPending('approve')}>
{ACTIONS.approve.label}
</button>
<button type="button" className="btn" onClick={() => setPending('takeover')}>
{ACTIONS.takeover.label}
</button>
</>
) : a.status === 'disabled' ? (
<button type="button" className="btn" onClick={() => setPending('enable')}>
{ACTIONS.enable.label}
</button>
) : (
<button type="button" className="btn" onClick={() => setPending('disable')}>
{ACTIONS.disable.label}
</button>
)}
<button type="button" className="btn danger" onClick={() => setPending('delete')}>
{ACTIONS.delete.label}
</button>
</>
) : null}
</span>
</header>
<ErrorNotice error={agent.error} />
{a ? (
<div className="stack">
<div className="card">
<div className="card-title">连着的 Probe({a.probes.length})</div>
{a.probes.length === 0 ? (
<Empty>现在没有 Probe 连着这个 Agent</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>Probe</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
<th />
</tr>
</thead>
<tbody>
{a.probes.map((p) => (
<tr key={p.probe_id}>
<td>
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)}</div>
<div className="mono faint">{p.probe_id}</div>
</td>
<td>{p.version ?? '—'}</td>
<td className="mono">{p.remote ?? '—'}</td>
<td className="muted">
{fmtTime(p.connected_since)}({fmtAgo(p.connected_since)})
</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}`}>
<FlaskConical size={14} />
测试
</Link>
<button
type="button"
className="btn small danger"
onClick={async () => {
await run(() => api.post(`/api/admin/agents/${a.id}/probes/${p.probe_id}/disconnect`), '已断开(Probe 会自动重连,除非已被吊销)')
agent.reload()
}}
>
<Unplug size={14} />
断开
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="grid cols-2">
<div className="card">
<div className="card-title">基本信息</div>
<dl className="kv">
<dt>ID</dt>
<dd className="mono">{a.id}</dd>
<dt>公开地址</dt>
<dd className="mono">{a.public_host || '—'}</dd>
<dt>控制端口</dt>
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
<dt>数据端口</dt>
<dd className="mono">{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}</dd>
<dt>版本</dt>
<dd>{a.agent_version ?? '—'}</dd>
<dt>展示页可见</dt>
<dd>{a.public_visible ? '是' : '否'}</dd>
<dt>注册时间</dt>
<dd>{fmtTime(a.created_at)}</dd>
<dt>最后在线</dt>
<dd>{fmtTime(a.last_seen_at)}</dd>
<dt>标签</dt>
<dd>
<div className="tags">
{a.labels.length ? a.labels.map((x) => <Chip key={x}>{x}</Chip>) : <span className="faint">—</span>}
</div>
</dd>
<dt>支持的采集</dt>
<dd>
<div className="tags">
{a.kinds.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
</dd>
</dl>
</div>
<div className="card">
<div className="card-title">运行状态</div>
<dl className="kv">
<dt>任务同步</dt>
<dd>
期望版本 {a.task_version},已应用 {a.applied_task_version}
{a.task_version !== a.applied_task_version ? <Chip tone="warn">未同步</Chip> : <Chip tone="ok">已同步</Chip>}
</dd>
{a.rejected.length ? (
<>
<dt>被拒绝的任务</dt>
<dd>
{a.rejected.map((r) => (
<div key={r.id}>
<span className="mono">{r.id}</span> <Chip tone="bad">{r.reason}</Chip>
</div>
))}
</dd>
</>
) : null}
<dt>NAT 端点变化</dt>
<dd>{a.endpoint_changes} 次</dd>
{a.heartbeat ? (
<>
<dt>心跳</dt>
<dd>{fmtAgo(a.heartbeat.at)}</dd>
<dt>收发包速率</dt>
<dd className="num">
入 {fmtNum(a.heartbeat.pps_in)} pps / 出 {fmtNum(a.heartbeat.pps_out)} pps
</dd>
<dt>丢弃(无效 / 限速)</dt>
<dd className="num">
{fmtNum(a.heartbeat.dropped_invalid)} / {fmtNum(a.heartbeat.dropped_rate_limited)}
</dd>
<dt>待上报队列</dt>
<dd className="num">{fmtNum(a.heartbeat.queue_bytes / 1024)} KiB</dd>
</>
) : null}
{a.local_limits ? (
<>
<dt>本地上限</dt>
<dd className="num">
{fmtNum(a.local_limits.max_total_mbps)} Mbit/s,{fmtNum(a.local_limits.max_total_pps)} pps,{a.local_limits.max_concurrent_tests} 个并发测试
</dd>
</>
) : null}
</dl>
</div>
</div>
<div className="card">
<div className="card-title">最近事件</div>
{(events.data?.items ?? []).length === 0 ? (
<Empty>没有事件</Empty>
) : (
<table className="table">
<tbody>
{events.data?.items.map((e) => (
<tr key={e.id}>
<td className="muted num" style={{ width: 160 }}>
{fmtTime(e.at)}
</td>
<td style={{ width: 200 }}>{e.kind}</td>
<td className="mono muted">{e.detail ?? ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
) : null}
{editing && a ? <EditDialog agent={a} onClose={() => setEditing(false)} onSaved={agent.reload} /> : null}
{pending ? (
<Confirm title={ACTIONS[pending].label} danger={ACTIONS[pending].danger} confirmText={ACTIONS[pending].label} onClose={() => setPending(undefined)} onConfirm={() => act(pending)}>
{ACTIONS[pending].text}
</Confirm>
) : null}
</>
)
}