Files
MoShiro/web/admin/src/pages/Tasks.tsx
T
2026-10-04 21:46:39 +08:00

318 lines
13 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, TaskDto } from '@shared/api/types'
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'
const kindOf = (t: TaskDto): ScopeFilter => (t.scope === 'target' ? 'target' : t.agent_id ? 'override' : 'template')
function summary(params: Record<string, unknown>): string {
const entries = Object.entries(params)
return entries.length === 0 ? '默认参数' : entries.map(([k, v]) => `${k}=${String(v)}`).join(' ')
}
/** 按 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)
}
return (
<div className="form-grid">
{params.map((p) => {
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>
)
})}
</div>
)
}
function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; 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 [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 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,
})
}
}, '已保存')
if (ok) {
onSaved()
onClose()
}
}
return (
<Dialog
title={editing ? '修改任务' : '添加任务'}
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>
</>
}
>
<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 ? (
<Field label="采集方法">
<select className="select" value={kindId} onChange={(e) => { setKindId(e.target.value); setValues({}) }}>
<option value="">选择</option>
{kindChoices.map((k) => (
<option key={k.id} value={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)} />
</Field>
) : null}
{kind ? (
<>
{kind.description ? <p className="muted">{kind.description}</p> : null}
<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>
)
}
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'>()
const [deleting, setDeleting] = useState<TaskDto>()
const { run } = useAction()
const all = tasks.data?.items ?? []
const list = all.filter((t) => filter === 'all' || kindOf(t) === filter)
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}
<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}
/>
<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}
kinds={kinds.data.items}
agents={agents.data.items}
templates={all.filter((t) => kindOf(t) === 'template')}
onClose={() => setEditing(undefined)}
onSaved={tasks.reload}
/>
) : null}
{deleting ? (
<Confirm
title="删除任务"
danger
confirmText="删除"
onClose={() => setDeleting(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/tasks/${deleting.id}`), '已删除')
tasks.reload()
}}
>
删除之后,Agent 和 Probe 会在下一次同步时停止这个任务。
</Confirm>
) : null}
</>
)
}