318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
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}
|
||
</>
|
||
)
|
||
}
|