chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
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}
</>
)
}