import { Pencil, Plus, Trash2 } from 'lucide-react' import { useState } from 'react' import { api } from '@shared/api/client' import type { AgentDto, KindDto, Page, ParamDto, TargetDto, TaskDto } from '@shared/api/types' import { TARGET_STATUS, rejectText } from '@shared/targets' import { fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' /** 任务分两类:Agent 之间(线路任务,两个 Agent 之间测)和单个 Agent(Agent 自己去测一个外部地址)。 */ type Category = 'between' | 'single' type Mode = 'template' | 'override' | 'own' /** 模板(对所有 Agent)、覆盖(改某个模板)、专属(只属于一个 Agent 的单个 Agent 任务)。 */ const modeOf = (t: TaskDto): Mode => (!t.agent_id ? 'template' : t.base_task_id ? 'override' : 'own') const categoryOf = (t: TaskDto): Category => (t.scope === 'target' ? 'single' : 'between') /** 采集方法的中文名、协议和一句话说明;Backend 目录里是英文,这里按 id 补充,没有登记的回退到目录里的标题。 */ const KIND_INFO: Record = { quic_ping: { name: 'QUIC 往返', proto: 'QUIC', desc: '两个 Agent 之间用 QUIC Datagram 持续往返,测 RTT、丢包、乱序;频率按丢包情况自适应。' }, tcp_ping: { name: 'TCP 往返', proto: 'TCP', desc: '两个 Agent 之间用 TCP 小消息持续往返,测 RTT、重传,并采集内核 tcp_info。' }, quic_stream: { name: 'QUIC 持续流量', proto: 'QUIC', desc: '按固定码率或阶梯发 QUIC Datagram,测高负载下的丢包和抖动。只用于按需测试。' }, tcp_transfer: { name: 'TCP 多流传输', proto: 'TCP', desc: '多条 TCP 连接并行灌包,测吞吐和重传。只用于按需测试。' }, tcp_connect: { name: 'TCP 建连', proto: 'TCP', desc: '只建立连接,不发 HTTP 请求:反复完成 TCP 三次握手(可选再做 TLS 握手),记录各阶段耗时和成功率。要测网页响应请用「HTTP 请求」。' }, tls_hold: { name: 'TLS 长连接保持', proto: 'TLS', desc: '保持一条 TLS 长连接并定期心跳,记录重置次数和存活时间。' }, icmp_ping: { name: 'ICMP 往返', proto: 'ICMP', desc: 'Agent 对目标地址发 ICMP echo,测 RTT 和丢包。' }, http_request: { name: 'HTTP 请求', proto: 'HTTP', desc: 'Agent 请求一个 URL(HTTP/1.1、HTTP/2 或 HTTP/3),记录 DNS、建连、首字节、总耗时和状态码;频率按失败率自适应。' }, } const kindName = (id: string, fallback?: string) => KIND_INFO[id]?.name ?? fallback ?? id /** 采集方法:中文名、协议标签和 id;用在任务列表和选择框里。 */ function KindCell({ id }: { id: string }) { const info = KIND_INFO[id] return (
{kindName(id)} {info ? `${info.proto} · ` : ''} {id}
) } /** 参数摘要:每个参数一个小标签,换行显示。 */ function ParamChips({ params }: { params: Record }) { const entries = Object.entries(params) if (entries.length === 0) return 默认参数 return ( {entries.map(([k, v]) => ( {k}={String(v)} ))} ) } /** 按 Kind 声明的参数规格生成表单:只提交和默认值不同的那些。 */ function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: Record; onChange: (v: Record) => void }) { const set = (name: string, value: unknown, fallback: unknown) => { const next = { ...values } if (value === '' || value === undefined || value === fallback) delete next[name] else next[name] = value onChange(next) } const field = (p: ParamDto) => { const current = values[p.name] const hint = [p.description, p.min !== null && p.min !== undefined ? `最小 ${p.min}` : '', p.max !== null && p.max !== undefined ? `最大 ${p.max}` : '', p.unit ?? ''].filter(Boolean).join(' · ') return ( {p.type === 'bool' ? ( ) : p.values && p.values.length > 0 ? ( ) : ( set(p.name, p.type === 'string' ? e.target.value : e.target.value === '' ? '' : Number(e.target.value), p.default)} /> )} ) } // 前几个是最常调的,其余折叠起来(自适应、超时这类参数很多时表单太长) const main = params.slice(0, 4) const more = params.slice(4) return ( <>
{main.map(field)}
{more.length > 0 ? (
更多参数({more.length})
{more.map(field)}
) : null} ) } function TaskDialog({ task, category, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; category: Category; kinds: KindDto[]; agents: AgentDto[]; templates: TaskDto[]; onClose: () => void; onSaved: () => void }) { const { busy, run } = useAction() const editing = Boolean(task) const [kindId, setKindId] = useState(task?.kind ?? '') const [everyone, setEveryone] = useState(task ? !task.agent_id : true) const [picked, setPicked] = useState(task?.agent_id ? [task.agent_id] : []) const [target, setTarget] = useState(task?.target ?? '') const [values, setValues] = useState>(task ? { ...(task.params as Record) } : {}) const [enabled, setEnabled] = useState(task?.enabled ?? true) const single = category === 'single' const kind = kinds.find((k) => k.id === kindId) // 「Agent 之间」的任务只有模板;只改某几个 Agent 时是覆盖这个方法的模板,所以要先有模板 const template = templates.find((t) => categoryOf(t) === category && t.kind === kindId) const blocked = !editing && !everyone && !single && !template const needsTarget = single && (everyone || !template) const kindChoices = kinds.filter((k) => k.scopes.includes(single ? 'target' : 'line')) const toggle = (id: string) => setPicked((current) => (current.includes(id) ? current.filter((x) => x !== id) : [...current, id])) const submit = async () => { const ok = await run(async () => { if (task) { await api.patch(`/api/admin/tasks/${task.id}`, { params: values, enabled, target: single && target ? target : undefined }) return } const base = { scope: single ? 'target' : 'line', kind: kindId, params: values, enabled } if (everyone) { await api.post('/api/admin/tasks', { ...base, target: single ? target : null, agent_id: null, base_task_id: null }) return } for (const agentId of picked) { await api.post('/api/admin/tasks', { ...base, target: single && target ? target : null, agent_id: agentId, base_task_id: !single || template ? (template?.id ?? null) : null }) } }, '已保存') if (ok) { onSaved() onClose() } } const scopeLine = editing ? task?.agent_id ? `作用于 ${agents.find((a) => a.id === task.agent_id)?.name ?? task.agent_id}` : '作用于所有 Agent,包括以后新加入的' : everyone ? '保存后立即对所有 Agent 生效,以后新加入的 Agent 也会自动执行' : single ? '只对选中的 Agent 生效' : '只调整选中 Agent 的参数(覆盖该方法的模板),其他 Agent 不变' return ( } >
{editing ? (
) : ( )} {single ? ( setTarget(e.target.value)} /> ) : null} {!editing && kind ? ( setEveryone(v === 'all')} /> ) : editing ? (

{scopeLine}

) : null} {!editing && kind && !everyone ? (
{agents.map((a) => ( ))}
) : null} {blocked ?

「{kindName(kindId)}」还没有对所有 Agent 的模板:先添加一个应用到「所有 Agent」的任务,再对某几个 Agent 单独调整。

: null} {kind ? ( <>

{KIND_INFO[kind.id]?.desc ?? kind.description}

) : null}
) } /** 单个 Agent 任务的运行情况:每个执行它的 Agent 一个状态标签;悬停看说明和原因。 */ function RunCell({ items }: { items: TargetDto[] }) { if (items.length === 0) return 没有 Agent 在执行 return ( {items.map((t) => ( {t.agent_name} · {TARGET_STATUS[t.status].label} ))} ) } /** 一个模板带着它的覆盖排在一起(覆盖缩进),其余的专属任务跟在后面;这样同一个方法不会在列表里到处出现。 */ function groupRows(tasks: TaskDto[]): { task: TaskDto; child: boolean }[] { const byId = new Set(tasks.map((t) => t.id)) const rows: { task: TaskDto; child: boolean }[] = [] for (const template of tasks.filter((t) => !t.agent_id)) { rows.push({ task: template, child: false }) for (const over of tasks.filter((t) => t.base_task_id === template.id)) rows.push({ task: over, child: true }) } // 模板已经不在的覆盖和专属任务 for (const t of tasks.filter((t) => t.agent_id && !(t.base_task_id && byId.has(t.base_task_id)))) rows.push({ task: t, child: false }) return rows } /** 作用范围:模板(所有 Agent)、覆盖(某个 Agent 改某个模板)、专属(只属于一个 Agent)。 */ function ScopeCell({ task, agentName }: { task: TaskDto; agentName: (id: string | null) => string }) { const mode = modeOf(task) return ( {mode === 'template' ? 模板 : mode === 'override' ? 覆盖 : 专属} {agentName(task.agent_id)} ) } interface TaskTableProps { title: string hint: string category: Category tasks: TaskDto[] loaded: boolean agentName: (id: string | null) => string onEdit: (t: TaskDto) => void onDelete: (t: TaskDto) => void onToggle: (t: TaskDto) => void onAdd: () => void canAdd: boolean targets: TargetDto[] } /** 一类任务一张表:Agent 之间的看「作用范围」,单个 Agent 的看「Agent」和「目标」。 */ function TaskTable({ title, hint, category, tasks, loaded, agentName, onEdit, onDelete, onToggle, onAdd, canAdd, targets }: TaskTableProps) { const single = category === 'single' return (
{title} {hint}
{tasks.length === 0 ? ( {loaded ? '没有任务' : '加载中…'} ) : ( {single ? : null} {single ? : null} {groupRows(tasks).map(({ task: t, child }) => ( {single ? : null} {single ? ( ) : null} ))}
采集方法 作用范围目标参数运行情况状态 更新
{child ? ↳ : } {t.target ?? 沿用模板} } /> x.task_id === t.id)} /> {fmtTime(t.updated_at)}
)}
) } export function Tasks() { const tasks = useQuery(() => api.get>('/api/admin/tasks'), []) const kinds = useQuery(() => api.get>('/api/public/catalog/kinds'), []) const agents = useQuery(() => api.get>('/api/admin/agents'), []) // 单个 Agent 任务有没有跑起来:每个 Agent 一个状态,10 秒刷新一次 const targets = useQuery(() => api.get>('/api/public/targets', { range: '1h' }), [], 10_000) // 新建时记下是哪一类(从哪张表点的添加),修改时是任务本身 const [editing, setEditing] = useState() const [deleting, setDeleting] = useState() const { run } = useAction() const all = tasks.data?.items ?? [] const between = all.filter((t) => categoryOf(t) === 'between') const single = all.filter((t) => categoryOf(t) === 'single') const toggle = async (t: TaskDto) => { await run(() => api.patch(`/api/admin/tasks/${t.id}`, { enabled: !t.enabled })) tasks.reload() } const agentName = (id: string | null) => (id ? (agents.data?.items.find((a) => a.id === id)?.name ?? id) : '全部 Agent') return ( <>

任务

{tasks.loading && !tasks.data ? : null}
setEditing('between')} canAdd={Boolean(kinds.data && agents.data)} targets={[]} /> setEditing('single')} canAdd={Boolean(kinds.data && agents.data)} targets={targets.data?.items ?? []} />
{editing && kinds.data && agents.data ? ( modeOf(t) === 'template')} onClose={() => setEditing(undefined)} onSaved={() => { tasks.reload(); targets.reload() }} /> ) : null} {deleting ? ( setDeleting(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/tasks/${deleting.id}`), '已删除') tasks.reload() }} > 删除之后,Agent 会在下一次同步时停止这个任务。 ) : null} ) }