feat(tasks): 删除任务可选连带删除资产/漏洞/流量/文件(默认不选)

删除弹窗新增四个默认不勾的复选框:
- 删除关联的资产:仅删本任务独有资产,与其他任务共享的仅解除关联(保留)。
- 删除发现的漏洞:删本任务全部 findings。
- 删除测试的流量:按本任务资产的 host 清录制流量(host 无任务标签,会连带清
  其他任务命中同一 host 的流量)。
- 删除测试过程中写的文件:删 <workDir>/tasks/<id> 目录(worker 产物 + 上传)。

- db: AssetStore.DeleteByTaskID(独有删+共享解关联)/HostsByTask;DeleteFindingsByTask。
- server: pgDeleteTask 读可选 JSON body {assets,findings,traffic,files},先按 host
  清流量(需在删资产前取 host)→删漏洞→删资产→删任务→删文件目录;空 body=只删任务。
- web: api.deleteTask(id,opts);删除弹窗四复选框(默认不选)+说明。

go build/vet、tsc 通过;新增 TestDeleteByTaskID(独有删/共享解关联/host 反查)。
This commit is contained in:
Autumn-27 committed 2026-08-19 10:40:19 -04:00
1 parent 2aeb2e49da
commit 4f7c1a199d
6 files changed
+215 -14

No files matched your search

+39
View File
@@ -1053,6 +1053,45 @@ func (s *AssetStore) GetByIDs(ids []int64) ([]*Asset, error) {
return scanAssets(rows)
}
// DeleteByTaskID removes a task's assets: hard-deletes assets owned solely by this
// task (task_ids = {taskID}) and de-associates the rest (drops this task from their
// task_ids, keeping assets shared with other tasks intact). Returns rows deleted.
func (s *AssetStore) DeleteByTaskID(taskID int64) (int64, error) {
res, err := s.db.Exec(`DELETE FROM assets WHERE task_ids = ARRAY[$1]::bigint[]`, taskID)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
if _, err := s.db.Exec(`UPDATE assets SET task_ids = array_remove(task_ids, $1) WHERE $1 = ANY(task_ids)`, taskID); err != nil {
return n, err
}
return n, nil
}
// HostsByTask returns the distinct hostnames/IPs the task's assets carry (domain,
// root_domain, ip) — used to purge host-keyed traffic recorded for that task.
func (s *AssetStore) HostsByTask(taskID int64) ([]string, error) {
rows, err := s.db.Query(`
SELECT DISTINCT h FROM (
SELECT NULLIF(domain,'') AS h FROM assets WHERE $1 = ANY(task_ids)
UNION SELECT NULLIF(root_domain,'') FROM assets WHERE $1 = ANY(task_ids)
UNION SELECT NULLIF(ip,'') FROM assets WHERE $1 = ANY(task_ids)
) q WHERE h IS NOT NULL AND h <> ''`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// DeleteByCompanyID hard-deletes all assets belonging to a company. Returns rows deleted.
func (s *AssetStore) DeleteByCompanyID(companyID int64) (int64, error) {
res, err := s.db.Exec(`DELETE FROM assets WHERE company_id = $1`, companyID)
+54
View File
@@ -2,6 +2,7 @@ package db
import (
"fmt"
"slices"
"testing"
)
@@ -535,6 +536,59 @@ func TestQueryByType(t *testing.T) {
}
}
// TestDeleteByTaskID: 独有资产被删,与其他任务共享的资产仅解除关联(保留),host 反查正确。
func TestDeleteByTaskID(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
const taskA = int64(90001)
const taskB = int64(90002)
// solo:仅属 taskA
solo, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "solo-del.test", TaskID: taskA})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, solo)
// shared:先 taskA 再 taskB → task_ids={A,B}
shared, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "shared-del.test", TaskID: taskA})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, shared)
if _, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "shared-del.test", TaskID: taskB}); err != nil {
t.Fatal(err)
}
// host 反查(删资产前):应含两个域名
hosts, err := av2.HostsByTask(taskA)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(hosts, "solo-del.test") || !slices.Contains(hosts, "shared-del.test") {
t.Fatalf("HostsByTask 缺 host: %v", hosts)
}
n, err := av2.DeleteByTaskID(taskA)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("DeleteByTaskID: 应删 1 个独有资产,实删 %d", n)
}
// solo 已删
if a, _ := av2.GetByIDs([]int64{solo}); len(a) != 0 {
t.Fatalf("solo 资产应被删除")
}
// shared 保留,且 task_ids 只剩 taskB
sa, _ := av2.GetByIDs([]int64{shared})
if len(sa) != 1 {
t.Fatalf("shared 资产应保留")
}
if slices.Contains(sa[0].TaskIDs, taskA) || !slices.Contains(sa[0].TaskIDs, taskB) {
t.Fatalf("shared task_ids 应解除 A 保留 B,得 %v", sa[0].TaskIDs)
}
}
func TestQueryByTask(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
+11
View File
@@ -329,6 +329,17 @@ func (d *DB) DeleteFinding(id int64) (int64, error) {
return 1, nil
}
// DeleteFindingsByTask removes all findings rows of a task. The originating
// exploration finding nodes are cascade-deleted separately when the task's
// exploration subgraph is dropped. Returns rows deleted.
func (d *DB) DeleteFindingsByTask(taskID int64) (int64, error) {
res, err := d.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// SetFindingStatus updates one finding's triage state. Returns rows affected.
func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id)
+48 -1
View File
@@ -66,14 +66,61 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod
// ---------- tasks (delete) ----------
// pgDeleteTask removes a task and, per opt-in flags in the (optional) JSON body,
// its associated data: assets (task-owned), findings, recorded traffic (by the
// task's hosts) and the per-task working directory. Empty body → all flags false →
// only the task + its exploration subgraph are removed (legacy behavior).
func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var opts struct {
Assets bool `json:"assets"`
Findings bool `json:"findings"`
Traffic bool `json:"traffic"`
Files bool `json:"files"`
}
_ = json.NewDecoder(r.Body).Decode(&opts) // optional body; ignore EOF/parse errors
result := map[string]any{"deleted": id}
nid, _ := strconv.ParseInt(id, 10, 64)
as := s.m.Assets()
// Traffic first: it needs the task's hosts, which come from assets that a later
// asset-delete would remove. Traffic is host-keyed (no per-task tag), so this
// purges ALL recorded traffic for those hosts, not just this task's.
if opts.Traffic && nid > 0 && as != nil {
if tr := s.m.Traffic(); tr != nil {
if hosts, err := as.HostsByTask(nid); err == nil && len(hosts) > 0 {
if n, err := tr.DeleteHostsExact(hosts); err == nil {
result["traffic_deleted"] = n
}
}
}
}
if opts.Findings && nid > 0 {
if n, err := s.m.pg.DeleteFindingsByTask(nid); err == nil {
result["findings_deleted"] = n
}
}
if opts.Assets && nid > 0 && as != nil {
if n, err := as.DeleteByTaskID(nid); err == nil {
result["assets_deleted"] = n
}
}
s.engine.Pause(id, agent.AbortTaskDeleted) // cancel running loops before removing
if err := s.m.DeleteTask(id); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": id})
// Files last: the per-task working dir <workDir>/tasks/<id> (worker artifacts +
// uploads). Confined to the work dir; id is numeric so no traversal risk.
if opts.Files && nid > 0 {
if err := os.RemoveAll(filepath.Join(s.m.dir, "tasks", id)); err == nil {
result["files_deleted"] = true
}
}
writeJSON(w, 200, result)
}
// ---------- agents ----------
+53 -12
View File
@@ -227,15 +227,18 @@ export default function TasksPage() {
return () => clearInterval(i);
}, [hasRunning]);
const deleteTask = React.useCallback(async (id: string) => {
try {
await api.deleteTask(id);
toast.success("任务已删除(全局资产图保留)");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}, [load]);
const deleteTask = React.useCallback(
async (id: string, opts: DeleteOpts) => {
try {
await api.deleteTask(id, opts);
toast.success("任务已删除");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
},
[load],
);
return (
<Card>
@@ -333,6 +336,15 @@ export default function TasksPage() {
// the POLL_MS list refresh only re-render the rows whose data actually moved — a table page
// is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild
// wholesale on every parent render.
type DeleteOpts = { assets: boolean; findings: boolean; traffic: boolean; files: boolean };
const DELETE_EXTRAS: { key: keyof DeleteOpts; label: string; hint: string }[] = [
{ key: "assets", label: "删除关联的资产", hint: "仅本任务独有的资产;与其他任务共享的仅解除关联" },
{ key: "findings", label: "删除发现的漏洞", hint: "本任务记录的全部漏洞" },
{ key: "traffic", label: "删除测试的流量", hint: "按本任务涉及的 host 清除录制流量" },
{ key: "files", label: "删除测试过程中写的文件", hint: "本任务工作目录 tasks/<id>(产物 + 上传)" },
];
const TaskRow = React.memo(function TaskRow({
task,
nowSec,
@@ -340,8 +352,14 @@ const TaskRow = React.memo(function TaskRow({
}: {
task: Task;
nowSec: number;
onDelete: (id: string) => void;
onDelete: (id: string, opts: DeleteOpts) => void;
}) {
const [delOpts, setDelOpts] = React.useState<DeleteOpts>({
assets: false,
findings: false,
traffic: false,
files: false,
});
return (
<TableRow className="group border-border/60">
<TableCell>
@@ -430,12 +448,35 @@ const TaskRow = React.memo(function TaskRow({
) : (
"该任务"
)}
的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。
的执行记录、会话与产物将被删除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
{/* 额外清理项:默认都不选,勾选后连带删除。全局资产图默认保留。 */}
<div className="grid gap-2 rounded-md border bg-muted/30 p-3">
<span className="text-xs font-medium text-muted-foreground">
同时删除以下关联数据(默认不删):
</span>
{DELETE_EXTRAS.map((opt) => (
<label key={opt.key} className="flex items-start gap-2 text-sm">
<Checkbox
className="mt-0.5"
checked={delOpts[opt.key]}
onCheckedChange={(v) =>
setDelOpts((s) => ({ ...s, [opt.key]: v === true }))
}
/>
<span className="grid gap-0.5">
<span>{opt.label}</span>
<span className="text-xs text-muted-foreground">{opt.hint}</span>
</span>
</label>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(task.id)}>
<AlertDialogAction onClick={() => onDelete(task.id, delOpts)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
+10 -1
View File
@@ -122,7 +122,16 @@ export const api = {
seed_first_intent: seedFirstIntent ?? false,
plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
}),
deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`),
// 删除任务;opts 里的四个可选项默认不删:assets 关联资产、findings 发现漏洞、
// traffic 测试流量(按任务 host 清)、files 测试过程写的文件(tasks/<id> 目录)。
deleteTask: (
id: string,
opts?: { assets?: boolean; findings?: boolean; traffic?: boolean; files?: boolean },
) =>
del<{ deleted: string; assets_deleted?: number; findings_deleted?: number; traffic_deleted?: number; files_deleted?: boolean }>(
`/tasks/${id}`,
opts,
),
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),