Files
MoShiro/web/admin/src/pages/AgentDetail.tsx
T
justme 84f9d211bc
ci/woodpecker/pr/verify Pipeline failed
ci/woodpecker/pr/web-build unknown status
backend: require confirmation for signed channel updates [CI]
2026-10-07 12:57:20 +08:00

345 lines
15 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'
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}
</>
)
}