Files
MoShiro/web/admin/src/pages/Tasks.tsx
T

458 lines
20 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 { 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}
</>
)
}