345 lines
15 KiB
TypeScript
345 lines
15 KiB
TypeScript
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'
|
||
import { IPV6_STATES } from '@shared/ipVersions'
|
||
|
||
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 [ipv6Host, setIpv6Host] = useState(agent.ipv6_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.trim(), ipv6_host: ipv6Host.trim(), 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="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)">
|
||
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} />
|
||
</Field>
|
||
</div>
|
||
<div className="wide">
|
||
<Field label="IPv6 地址" hint="IPv6 地址或域名(AAAA 记录);留空只采集 IPv4">
|
||
<input className="input" value={ipv6Host} onChange={(e) => setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" />
|
||
</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}~${p.ip_version}`}>
|
||
<td>
|
||
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)} <Chip>{p.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip></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}&ip_version=${p.ip_version}`}>
|
||
<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>IPv4 地址</dt>
|
||
<dd className="mono">{a.public_host || '—'}</dd>
|
||
<dt>IPv6 地址</dt>
|
||
<dd className="mono">{a.ipv6_host || '未配置'}</dd>
|
||
<dt>IPv6 监听</dt>
|
||
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
|
||
<dt>IPv6 诊断</dt>
|
||
<dd>{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'}</dd>
|
||
<dt>检测网络</dt>
|
||
<dd>{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}</dd>
|
||
<dt>宿主全局 IPv6</dt>
|
||
<dd>{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}</dd>
|
||
<dt>全局 IPv6 地址</dt>
|
||
<dd>{a.ipv6?.has_global_address == null ? '未知' : a.ipv6.has_global_address ? '已获得' : '未获得'}</dd>
|
||
<dt>IPv6 默认路由</dt>
|
||
<dd>{a.ipv6?.has_default_route == null ? '未知' : a.ipv6.has_default_route ? '存在' : '缺失'}</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}
|
||
</>
|
||
)
|
||
}
|