458 lines
20 KiB
TypeScript
458 lines
20 KiB
TypeScript
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<string, { name: string; proto: string; desc: string }> = {
|
||
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 (
|
||
<div className="task-kind">
|
||
<b>{kindName(id)}</b>
|
||
<span className="faint task-kind-id">
|
||
{info ? `${info.proto} · ` : ''}
|
||
<span className="mono">{id}</span>
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 参数摘要:每个参数一个小标签,换行显示。 */
|
||
function ParamChips({ params }: { params: Record<string, unknown> }) {
|
||
const entries = Object.entries(params)
|
||
if (entries.length === 0) return <span className="faint">默认参数</span>
|
||
return (
|
||
<span className="task-params">
|
||
{entries.map(([k, v]) => (
|
||
<span key={k} className="mono">
|
||
{k}=<b>{String(v)}</b>
|
||
</span>
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** 按 Kind 声明的参数规格生成表单:只提交和默认值不同的那些。 */
|
||
function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: Record<string, unknown>; onChange: (v: Record<string, unknown>) => 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 (
|
||
<Field key={p.name} label={`${p.name}(默认 ${JSON.stringify(p.default)})`} hint={hint}>
|
||
{p.type === 'bool' ? (
|
||
<select className="select" value={current === undefined ? '' : String(current)} onChange={(e) => set(p.name, e.target.value === '' ? undefined : e.target.value === 'true', p.default)}>
|
||
<option value="">默认</option>
|
||
<option value="true">是</option>
|
||
<option value="false">否</option>
|
||
</select>
|
||
) : p.values && p.values.length > 0 ? (
|
||
<select className="select" value={current === undefined ? '' : String(current)} onChange={(e) => set(p.name, e.target.value, p.default)}>
|
||
<option value="">默认</option>
|
||
{p.values.map((v) => (
|
||
<option key={String(v)} value={String(v)}>
|
||
{String(v)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<input
|
||
className="input"
|
||
type={p.type === 'string' ? 'text' : 'number'}
|
||
step={p.type === 'int' ? 1 : 'any'}
|
||
value={current === undefined ? '' : String(current)}
|
||
placeholder={String(p.default)}
|
||
onChange={(e) => set(p.name, p.type === 'string' ? e.target.value : e.target.value === '' ? '' : Number(e.target.value), p.default)}
|
||
/>
|
||
)}
|
||
</Field>
|
||
)
|
||
}
|
||
// 前几个是最常调的,其余折叠起来(自适应、超时这类参数很多时表单太长)
|
||
const main = params.slice(0, 4)
|
||
const more = params.slice(4)
|
||
return (
|
||
<>
|
||
<div className="form-grid">{main.map(field)}</div>
|
||
{more.length > 0 ? (
|
||
<details className="param-more">
|
||
<summary>更多参数({more.length})</summary>
|
||
<div className="form-grid">{more.map(field)}</div>
|
||
</details>
|
||
) : 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<string[]>(task?.agent_id ? [task.agent_id] : [])
|
||
const [target, setTarget] = useState(task?.target ?? '')
|
||
const [values, setValues] = useState<Record<string, unknown>>(task ? { ...(task.params as Record<string, unknown>) } : {})
|
||
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 (
|
||
<Dialog
|
||
title={editing ? '修改任务' : single ? '添加单个 Agent 任务' : '添加 Agent 之间的任务'}
|
||
onClose={onClose}
|
||
actions={
|
||
<>
|
||
<button type="button" className="btn text" onClick={onClose}>
|
||
取消
|
||
</button>
|
||
<button type="button" className="btn primary" disabled={busy || !kind || blocked || (needsTarget && !target) || (!editing && !everyone && picked.length === 0)} onClick={submit}>
|
||
保存
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="stack">
|
||
{editing ? (
|
||
<div className="task-kind">
|
||
<KindCell id={task!.kind} />
|
||
</div>
|
||
) : (
|
||
<Field label="采集方法">
|
||
<select className="select" value={kindId} onChange={(e) => { setKindId(e.target.value); setValues({}); setTarget('') }}>
|
||
<option value="">选择要做什么测量</option>
|
||
{kindChoices.map((k) => (
|
||
<option key={k.id} value={k.id}>
|
||
{kindName(k.id, k.title)}({k.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
)}
|
||
{single ? (
|
||
<Field label="目标" hint={kindId === 'http_request' ? 'http:// 或 https:// 开头的 URL,例如 https://example.com/health' : 'IP 或域名,可带端口,例如 1.1.1.1、example.com:443'}>
|
||
<input className="input" value={target} placeholder={!everyone && template ? (template.target ?? '') : ''} onChange={(e) => setTarget(e.target.value)} />
|
||
</Field>
|
||
) : null}
|
||
{!editing && kind ? (
|
||
<Field label="应用到" hint={scopeLine}>
|
||
<Segmented
|
||
options={[
|
||
{ value: 'all' as const, label: '所有 Agent' },
|
||
{ value: 'some' as const, label: '指定 Agent' },
|
||
]}
|
||
value={everyone ? 'all' : 'some'}
|
||
onChange={(v) => setEveryone(v === 'all')}
|
||
/>
|
||
</Field>
|
||
) : editing ? (
|
||
<p className="muted">{scopeLine}</p>
|
||
) : null}
|
||
{!editing && kind && !everyone ? (
|
||
<div className="task-agents">
|
||
{agents.map((a) => (
|
||
<label key={a.id} className="switch">
|
||
<input type="checkbox" checked={picked.includes(a.id)} onChange={() => toggle(a.id)} />
|
||
{a.name}
|
||
</label>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{blocked ? <p className="warn-text">「{kindName(kindId)}」还没有对所有 Agent 的模板:先添加一个应用到「所有 Agent」的任务,再对某几个 Agent 单独调整。</p> : null}
|
||
{kind ? (
|
||
<>
|
||
<p className="muted">{KIND_INFO[kind.id]?.desc ?? kind.description}</p>
|
||
<ParamForm params={kind.params} values={values} onChange={setValues} />
|
||
</>
|
||
) : null}
|
||
<label className="switch">
|
||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||
启用
|
||
</label>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
/** 单个 Agent 任务的运行情况:每个执行它的 Agent 一个状态标签;悬停看说明和原因。 */
|
||
function RunCell({ items }: { items: TargetDto[] }) {
|
||
if (items.length === 0) return <span className="faint">没有 Agent 在执行</span>
|
||
return (
|
||
<span className="task-run">
|
||
{items.map((t) => (
|
||
<span key={t.agent_id} title={`${TARGET_STATUS[t.status].hint}${t.status === 'rejected' ? `\n${rejectText(t.reason)}` : ''}${t.last_sample ? `\n最后一次数据 ${fmtTime(t.last_sample)}` : ''}`}>
|
||
<Chip tone={TARGET_STATUS[t.status].tone}>
|
||
{t.agent_name} · {TARGET_STATUS[t.status].label}
|
||
</Chip>
|
||
</span>
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** 一个模板带着它的覆盖排在一起(覆盖缩进),其余的专属任务跟在后面;这样同一个方法不会在列表里到处出现。 */
|
||
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 (
|
||
<span className="row" style={{ gap: 6 }}>
|
||
{mode === 'template' ? <Chip tone="accent">模板</Chip> : mode === 'override' ? <Chip>覆盖</Chip> : <Chip>专属</Chip>}
|
||
<span className={mode === 'template' ? 'muted' : undefined}>{agentName(task.agent_id)}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<section className="card" style={{ padding: '8px 12px' }}>
|
||
<div className="row" style={{ alignItems: 'baseline', gap: 8, padding: '4px 0 8px' }}>
|
||
<b>{title}</b>
|
||
<span className="muted">{hint}</span>
|
||
<button type="button" className="btn small right" onClick={onAdd} disabled={!canAdd}>
|
||
<Plus size={14} />
|
||
添加任务
|
||
</button>
|
||
</div>
|
||
{tasks.length === 0 ? (
|
||
<Empty>{loaded ? '没有任务' : '加载中…'}</Empty>
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>采集方法</th>
|
||
<th>作用范围</th>
|
||
{single ? <th>目标</th> : null}
|
||
<th>参数</th>
|
||
{single ? <th>运行情况</th> : null}
|
||
<th>状态</th>
|
||
<th>更新</th>
|
||
<th />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{groupRows(tasks).map(({ task: t, child }) => (
|
||
<tr key={t.id} className={child ? 'task-child' : undefined}>
|
||
<td>{child ? <span className="task-branch">↳</span> : <KindCell id={t.kind} />}</td>
|
||
<td>
|
||
<ScopeCell task={t} agentName={agentName} />
|
||
</td>
|
||
{single ? <td className="mono">{t.target ?? <span className="faint">沿用模板</span>}</td> : null}
|
||
<td>
|
||
<ParamChips params={t.params as Record<string, unknown>} />
|
||
</td>
|
||
{single ? (
|
||
<td>
|
||
<RunCell items={targets.filter((x) => x.task_id === t.id)} />
|
||
</td>
|
||
) : null}
|
||
<td>
|
||
<button type="button" className="btn small" onClick={() => onToggle(t)}>
|
||
{t.enabled ? <Chip tone="ok">已启用</Chip> : <Chip>已停用</Chip>}
|
||
</button>
|
||
</td>
|
||
<td className="muted">{fmtTime(t.updated_at)}</td>
|
||
<td className="r">
|
||
<span className="row" style={{ justifyContent: 'flex-end' }}>
|
||
<button type="button" className="btn small" onClick={() => onEdit(t)}>
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button type="button" className="btn small danger" onClick={() => onDelete(t)}>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export function Tasks() {
|
||
const tasks = useQuery(() => api.get<Page<TaskDto>>('/api/admin/tasks'), [])
|
||
const kinds = useQuery(() => api.get<Page<KindDto>>('/api/public/catalog/kinds'), [])
|
||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [])
|
||
// 单个 Agent 任务有没有跑起来:每个 Agent 一个状态,10 秒刷新一次
|
||
const targets = useQuery(() => api.get<Page<TargetDto>>('/api/public/targets', { range: '1h' }), [], 10_000)
|
||
// 新建时记下是哪一类(从哪张表点的添加),修改时是任务本身
|
||
const [editing, setEditing] = useState<TaskDto | Category>()
|
||
const [deleting, setDeleting] = useState<TaskDto>()
|
||
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 (
|
||
<>
|
||
<header>
|
||
<h1>任务</h1>
|
||
<span className="right row">
|
||
{tasks.loading && !tasks.data ? <Spinner /> : null}
|
||
</span>
|
||
</header>
|
||
<ErrorNotice error={tasks.error ?? kinds.error} />
|
||
<div className="stack">
|
||
<TaskTable
|
||
title="Agent 之间"
|
||
hint="两个 Agent 之间的线路测量:模板对所有线路生效,覆盖只改某个 Agent 的参数"
|
||
category="between"
|
||
tasks={between}
|
||
loaded={Boolean(tasks.data)}
|
||
agentName={agentName}
|
||
onEdit={setEditing}
|
||
onDelete={setDeleting}
|
||
onToggle={toggle}
|
||
onAdd={() => setEditing('between')}
|
||
canAdd={Boolean(kinds.data && agents.data)}
|
||
targets={[]}
|
||
/>
|
||
<TaskTable
|
||
title="单个 Agent"
|
||
hint="Agent 自己去测一个外部地址:模板对所有 Agent 生效,也可以只给某个 Agent 专属或覆盖"
|
||
category="single"
|
||
tasks={single}
|
||
loaded={Boolean(tasks.data)}
|
||
agentName={agentName}
|
||
onEdit={setEditing}
|
||
onDelete={setDeleting}
|
||
onToggle={toggle}
|
||
onAdd={() => setEditing('single')}
|
||
canAdd={Boolean(kinds.data && agents.data)}
|
||
targets={targets.data?.items ?? []}
|
||
/>
|
||
</div>
|
||
{editing && kinds.data && agents.data ? (
|
||
<TaskDialog
|
||
task={typeof editing === 'string' ? undefined : editing}
|
||
category={typeof editing === 'string' ? editing : categoryOf(editing)}
|
||
kinds={kinds.data.items}
|
||
agents={agents.data.items}
|
||
templates={all.filter((t) => modeOf(t) === 'template')}
|
||
onClose={() => setEditing(undefined)}
|
||
onSaved={() => { tasks.reload(); targets.reload() }}
|
||
/>
|
||
) : null}
|
||
{deleting ? (
|
||
<Confirm
|
||
title="删除任务"
|
||
danger
|
||
confirmText="删除"
|
||
onClose={() => setDeleting(undefined)}
|
||
onConfirm={async () => {
|
||
await run(() => api.del(`/api/admin/tasks/${deleting.id}`), '已删除')
|
||
tasks.reload()
|
||
}}
|
||
>
|
||
删除之后,Agent 会在下一次同步时停止这个任务。
|
||
</Confirm>
|
||
) : null}
|
||
</>
|
||
)
|
||
}
|