web: split the task page into agent-to-agent and single-agent tasks with templates and run status

This commit is contained in:
justme committed 2026-10-22 07:02:31 +08:00
1 parent 3a9ea46d6f
commit 7ad226f29c
5 files changed
+401 -162

No files matched your search

+62
View File
@@ -548,3 +548,65 @@
.agent-ipv6 > .kv {
margin-top: 8px;
}
.task-kind {
display: grid;
gap: 2px;
}
.task-params {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
max-width: 360px;
font-size: 12px;
color: var(--app-ink-muted);
}
.task-params b {
color: var(--app-ink);
font-weight: 500;
}
.param-more {
margin-top: 4px;
}
.param-more > summary {
cursor: pointer;
margin-bottom: 8px;
color: var(--app-ink-muted);
font-size: 13px;
}
.task-agents {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px 12px;
max-height: 160px;
overflow: auto;
padding: 8px 12px;
border: 1px solid var(--app-outline-soft);
border-radius: 12px;
}
.task-kind-id {
font-size: 12px;
}
.task-child td {
padding-top: 6px;
padding-bottom: 6px;
}
.task-child .task-branch {
display: inline-block;
padding-left: 14px;
color: var(--app-ink-faint);
}
.task-run {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
+1 -1
View File
@@ -752,7 +752,7 @@ function Raw() {
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="Agent ID" value={agent} onChange={(e) => setAgent(e.target.value)} />
<input className="input" style={{ width: 200 }} placeholder="采集方法,例如 udp_echo" value={kind} onChange={(e) => setKind(e.target.value)} />
<input className="input" style={{ width: 200 }} placeholder="采集方法,例如 quic_ping" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="muted">原始块按保留期限保存,可以下载后离线分析。</span>
</div>
<ErrorNotice error={chunks.error} />
+301 -161
View File
@@ -1,18 +1,61 @@
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, KindDto, Page, ParamDto, TaskDto } from '@shared/api/types'
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'
type ScopeFilter = 'all' | 'template' | 'override' | 'target'
/** 任务分两类:Agent 之间(线路任务,两个 Agent 之间测)和单个 Agent(Agent 自己去测一个外部地址)。 */
type Category = 'between' | 'single'
type Mode = 'template' | 'override' | 'own'
const kindOf = (t: TaskDto): ScopeFilter => (t.scope === 'target' ? 'target' : t.agent_id ? 'override' : 'template')
/** 模板(对所有 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')
function summary(params: Record<string, unknown>): string {
/** 采集方法的中文名、协议和一句话说明;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)
return entries.length === 0 ? '默认参数' : entries.map(([k, v]) => `${k}=${String(v)}`).join(' ')
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 声明的参数规格生成表单:只提交和默认值不同的那些。 */
@@ -23,9 +66,7 @@ function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: R
else next[name] = value
onChange(next)
}
return (
<div className="form-grid">
{params.map((p) => {
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 (
@@ -57,41 +98,56 @@ function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: R
)}
</Field>
)
})}
</div>
}
// 前几个是最常调的,其余折叠起来(自适应、超时这类参数很多时表单太长)
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, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; kinds: KindDto[]; agents: AgentDto[]; templates: TaskDto[]; onClose: () => void; onSaved: () => void }) {
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 [mode, setMode] = useState<'template' | 'override' | 'target'>(task ? (kindOf(task) as 'template' | 'override' | 'target') : 'override')
const [kindId, setKindId] = useState(task?.kind ?? '')
const [agentId, setAgentId] = useState(task?.agent_id ?? agents[0]?.id ?? '')
const [baseId, setBaseId] = useState(task?.base_task_id ?? '')
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 base = templates.find((t) => t.id === baseId)
const effectiveKind = mode === 'override' ? (base?.kind ?? '') : kindId
const kind = kinds.find((k) => k.id === effectiveKind)
const kindChoices = kinds.filter((k) => k.scopes.includes(mode === 'target' ? 'target' : 'line'))
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: mode === 'target' ? target : undefined })
} else {
await api.post('/api/admin/tasks', {
scope: mode === 'target' ? 'target' : 'line',
kind: effectiveKind,
target: mode === 'target' ? target : null,
params: values,
enabled,
agent_id: mode === 'template' ? null : agentId,
base_task_id: mode === 'override' ? baseId : null,
})
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) {
@@ -100,82 +156,81 @@ function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task
}
}
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 ? '修改任务' : '添加任务'}
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 || !effectiveKind || (mode !== 'template' && !agentId) || (mode === 'target' && !target) || (mode === 'override' && !baseId)} onClick={submit}>
<button type="button" className="btn primary" disabled={busy || !kind || blocked || (needsTarget && !target) || (!editing && !everyone && picked.length === 0)} onClick={submit}>
保存
</button>
</>
}
>
<div className="stack">
{!editing ? (
<Field label="类型" hint={mode === 'template' ? '线路模板:对所有 Agent 生效' : mode === 'override' ? '覆盖:只改某个 Agent 上某个模板的参数' : '目标任务:Agent 自己去测一个地址(ICMP、TCP、TLS 建连),需要 Agent 本地允许这个网段'}>
<Segmented
options={[
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'target' as const, label: '目标任务' },
]}
value={mode}
onChange={(v) => {
setMode(v)
setValues({})
setKindId('')
}}
/>
</Field>
) : null}
{mode !== 'template' ? (
<Field label="Agent">
<select className="select" value={agentId} disabled={editing} onChange={(e) => setAgentId(e.target.value)}>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
) : null}
{mode === 'override' && !editing ? (
<Field label="要覆盖的模板">
<select className="select" value={baseId} onChange={(e) => { setBaseId(e.target.value); setValues({}) }}>
<option value="">选择模板</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.kind}({summary(t.params as Record<string, unknown>)})
</option>
))}
</select>
</Field>
) : null}
{mode !== 'override' && !editing ? (
{editing ? (
<div className="task-kind">
<KindCell id={task!.kind} />
</div>
) : (
<Field label="采集方法">
<select className="select" value={kindId} onChange={(e) => { setKindId(e.target.value); setValues({}) }}>
<option value="">选择</option>
<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}>
{k.title}({k.id})
{kindName(k.id, k.title)}({k.id})
</option>
))}
</select>
</Field>
) : null}
{mode === 'target' ? (
<Field label="目标" hint="例如 1.1.1.1 或 example.com:443">
<input className="input" value={target} onChange={(e) => setTarget(e.target.value)} />
)}
{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 ? (
<>
{kind.description ? <p className="muted">{kind.description}</p> : null}
<p className="muted">{KIND_INFO[kind.id]?.desc ?? kind.description}</p>
<ParamForm params={kind.params} values={values} onChange={setValues} />
</>
) : null}
@@ -188,17 +243,149 @@ function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task
)
}
/** 单个 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'), [])
const [filter, setFilter] = useState<ScopeFilter>('all')
const [editing, setEditing] = useState<TaskDto | 'new'>()
// 单个 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 list = all.filter((t) => filter === 'all' || kindOf(t) === filter)
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 (
@@ -207,95 +394,48 @@ export function Tasks() {
<h1>任务</h1>
<span className="right row">
{tasks.loading && !tasks.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setEditing('new')} disabled={!kinds.data || !agents.data}>
<Plus size={18} />
添加任务
</button>
</span>
</header>
<ErrorNotice error={tasks.error ?? kinds.error} />
<div className="stack">
<Segmented
options={[
{ value: 'all' as const, label: '全部' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'target' as const, label: '目标任务' },
]}
value={filter}
onChange={setFilter}
<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 className="card" style={{ padding: '8px 12px' }}>
{list.length === 0 ? (
<Empty>{tasks.data ? '没有任务' : '加载中…'}</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>采集方法</th>
<th>类型</th>
<th>作用于</th>
<th>参数</th>
<th>状态</th>
<th>更新</th>
<th />
</tr>
</thead>
<tbody>
{list.map((t) => {
const k = kindOf(t)
return (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.kind}</td>
<td>
<Chip tone={k === 'target' ? 'accent' : ''}>{k === 'template' ? '线路模板' : k === 'override' ? 'Agent 覆盖' : '目标任务'}</Chip>
</td>
<td>
{agentName(t.agent_id)}
{t.target ? <div className="mono faint">{t.target}</div> : null}
</td>
<td className="mono muted" style={{ maxWidth: 320, overflowWrap: 'anywhere' }}>
{summary(t.params as Record<string, unknown>)}
</td>
<td>
<button
type="button"
className="btn small"
onClick={async () => {
await run(() => api.patch(`/api/admin/tasks/${t.id}`, { enabled: !t.enabled }))
tasks.reload()
}}
>
{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={() => setEditing(t)}>
<Pencil size={14} />
</button>
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
{editing && kinds.data && agents.data ? (
<TaskDialog
task={editing === 'new' ? undefined : editing}
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) => kindOf(t) === 'template')}
templates={all.filter((t) => modeOf(t) === 'template')}
onClose={() => setEditing(undefined)}
onSaved={tasks.reload}
onSaved={() => { tasks.reload(); targets.reload() }}
/>
) : null}
{deleting ? (