diff --git a/web/admin/src/admin.css b/web/admin/src/admin.css
index b6d3b38..fd8aef8 100644
--- a/web/admin/src/admin.css
+++ b/web/admin/src/admin.css
@@ -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;
+}
diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx
index b66ae7b..2ac3f2f 100644
--- a/web/admin/src/pages/System.tsx
+++ b/web/admin/src/pages/System.tsx
@@ -752,7 +752,7 @@ function Raw() {
setAgent(e.target.value)} />
- setKind(e.target.value)} />
+ setKind(e.target.value)} />
原始块按保留期限保存,可以下载后离线分析。
diff --git a/web/admin/src/pages/Tasks.tsx b/web/admin/src/pages/Tasks.tsx
index 036b608..f17c5bd 100644
--- a/web/admin/src/pages/Tasks.tsx
+++ b/web/admin/src/pages/Tasks.tsx
@@ -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 {
+/** 采集方法的中文名、协议和一句话说明;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)
- return entries.length === 0 ? '默认参数' : entries.map(([k, v]) => `${k}=${String(v)}`).join(' ')
+ if (entries.length === 0) return 默认参数
+ return (
+
+ {entries.map(([k, v]) => (
+
+ {k}={String(v)}
+
+ ))}
+
+ )
}
/** 按 Kind 声明的参数规格生成表单:只提交和默认值不同的那些。 */
@@ -23,9 +66,7 @@ function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: R
else next[name] = value
onChange(next)
}
- return (
-
- {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
)}
)
- })}
-
+ }
+ // 前几个是最常调的,其余折叠起来(自适应、超时这类参数很多时表单太长)
+ 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, 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(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 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 (