chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -0,0 +1,317 @@
|
||||
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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user