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 ( - } >
- {!editing ? ( - - { - setMode(v) - setValues({}) - setKindId('') - }} - /> - - ) : null} - {mode !== 'template' ? ( - - - - ) : null} - {mode === 'override' && !editing ? ( - - - - ) : null} - {mode !== 'override' && !editing ? ( + {editing ? ( +
+ +
+ ) : ( - { setKindId(e.target.value); setValues({}); setTarget('') }}> + {kindChoices.map((k) => ( ))} - ) : null} - {mode === 'target' ? ( - - setTarget(e.target.value)} /> + )} + {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.description ?

{kind.description}

: null} +

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

) : 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 没有 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'), []) - const [filter, setFilter] = useState('all') - const [editing, setEditing] = useState() + // 单个 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 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() {

任务

{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 ?? []} /> -
- {list.length === 0 ? ( - {tasks.data ? '没有任务' : '加载中…'} - ) : ( - - - - - - - - - - - - - {list.map((t) => { - const k = kindOf(t) - return ( - - - - - - - - - - ) - })} - -
采集方法类型作用于参数状态更新 -
{t.kind} - {k === 'template' ? '线路模板' : k === 'override' ? 'Agent 覆盖' : '目标任务'} - - {agentName(t.agent_id)} - {t.target ?
{t.target}
: null} -
- {summary(t.params as Record)} - - - {fmtTime(t.updated_at)} - - - - -
- )} -
{editing && kinds.data && agents.data ? ( kindOf(t) === 'template')} + templates={all.filter((t) => modeOf(t) === 'template')} onClose={() => setEditing(undefined)} - onSaved={tasks.reload} + onSaved={() => { tasks.reload(); targets.reload() }} /> ) : null} {deleting ? ( diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index aa31dc6..9a19d66 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -69,3 +69,20 @@ export interface Page { /** 后端数字有时按 integer|string 声明:统一成 number。 */ export const n = (v: number | string | null | undefined): number => (v === null || v === undefined ? 0 : Number(v)) + +export interface TargetDto { + task_id: string + agent_id: string + agent_name: string + kind: string + target: string + status: 'running' | 'waiting' | 'stale' | 'rejected' | 'offline' + reason?: string | null + last_sample?: string | null + from: string + to: string + step: string + t: number[] + values: Record + summary: { metrics: Record; samples: Record; coverage: number } +} diff --git a/web/shared/targets.ts b/web/shared/targets.ts new file mode 100644 index 0000000..8aef619 --- /dev/null +++ b/web/shared/targets.ts @@ -0,0 +1,20 @@ +import type { TargetDto } from './api/types' + +/** 单个 Agent 任务的状态:文字、颜色和一句说明。 */ +export const TARGET_STATUS: Record = { + running: { label: '运行中', tone: 'ok', hint: '最近 15 分钟内有数据' }, + waiting: { label: '等待数据', tone: 'warn', hint: '任务已下发,Agent 还没上传第一批数据(每 10 秒一批)' }, + stale: { label: '数据中断', tone: 'warn', hint: '以前有数据,最近 15 分钟没有新数据' }, + rejected: { label: '被拒绝', tone: 'bad', hint: 'Agent 本机的限制不允许执行这个任务' }, + offline: { label: 'Agent 离线', tone: '', hint: 'Agent 不在线,任务会在它回来后继续' }, +} + +/** Agent 拒绝任务的原因,翻成人话。 */ +export const REJECT_REASONS: Record = { + policy_denied: '本机不允许这个目标:把它所在的网段加进 agent.json 的 limits.allowed_target_cidrs', + budget_exceeded: '超出本机的速率预算', + unsupported: '这个 Agent 不支持这种采集方法', + invalid_params: '参数不合法', +} + +export const rejectText = (reason?: string | null) => (reason ? (REJECT_REASONS[reason] ?? reason) : '')