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:
1 parent
2aeb2e49da
commit
4f7c1a199d
6 files changed
+215
-14
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 ----------
|
||||
|
||||
@@ -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
@@ -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`),
|
||||
|
||||
Reference in new issue
Block a user