chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 26620707a0
347 files changed
+56172

No files matched your search

+317
View File
@@ -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}
</>
)
}