feat(goals): 总览新增目标管理(增/删/改),同步通知 planner 并复活任务
- 总览页新增「目标管理」卡片,支持人工查看/新增/修改/删除目标 - 新增/修改:通知 planner(新增了目标/由 old 变为 new)并复活任务 - 删除:硬删除 goal 节点(级联删边/锚点),通知 planner,不复活任务 - 无 schema 变更:目标仍存 exploration_nodes(kind='goal'),仅加查询方法
This commit is contained in:
1 parent
025e1ce445
commit
d28ecd67fe
9 files changed
+506
-2
No files matched your search
@@ -6,13 +6,17 @@ import {
|
||||
ActivityIcon,
|
||||
AlertTriangleIcon,
|
||||
BugIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CoinsIcon,
|
||||
ListChecksIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
@@ -22,7 +26,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, ModelTokenStat, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types";
|
||||
import type { Finding, ModelTokenStat, Stats, Task, TaskGoal, TaskNode, TaskScopeRow } from "@/lib/types";
|
||||
|
||||
// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。
|
||||
function fmtTokens(n: number): string {
|
||||
@@ -106,6 +110,15 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
const [scopeErr, setScopeErr] = React.useState("");
|
||||
// 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。
|
||||
const [modelTokens, setModelTokens] = React.useState<ModelTokenStat[]>([]);
|
||||
// 目标管理:目标列表 + 新增表单 + 行内编辑状态。
|
||||
const [goals, setGoals] = React.useState<TaskGoal[]>([]);
|
||||
const [goalText, setGoalText] = React.useState("");
|
||||
const [goalVuln, setGoalVuln] = React.useState("");
|
||||
const [goalBusy, setGoalBusy] = React.useState(false);
|
||||
const [goalErr, setGoalErr] = React.useState("");
|
||||
const [editingGoalId, setEditingGoalId] = React.useState<string | null>(null);
|
||||
const [editText, setEditText] = React.useState("");
|
||||
const [editVuln, setEditVuln] = React.useState("");
|
||||
|
||||
const loadTokens = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -125,6 +138,69 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const loadGoals = React.useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.taskGoals(taskId);
|
||||
setGoals(resp.goals);
|
||||
} catch {
|
||||
// 忽略:瞬时错误,下次轮询重试
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const addGoal = async () => {
|
||||
const text = goalText.trim();
|
||||
if (!text) return;
|
||||
setGoalBusy(true);
|
||||
setGoalErr("");
|
||||
try {
|
||||
await api.addGoal(taskId, text, goalVuln.trim() || undefined);
|
||||
setGoalText("");
|
||||
setGoalVuln("");
|
||||
await loadGoals();
|
||||
} catch (e) {
|
||||
setGoalErr(e instanceof Error ? e.message : "添加失败");
|
||||
} finally {
|
||||
setGoalBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditGoal = (g: TaskGoal) => {
|
||||
setEditingGoalId(g.id);
|
||||
setEditText(g.text);
|
||||
setEditVuln(g.vulnclass ?? "");
|
||||
};
|
||||
|
||||
const cancelEditGoal = () => {
|
||||
setEditingGoalId(null);
|
||||
setEditText("");
|
||||
setEditVuln("");
|
||||
};
|
||||
|
||||
const saveEditGoal = async (g: TaskGoal) => {
|
||||
const text = editText.trim();
|
||||
if (!text) return;
|
||||
setGoalBusy(true);
|
||||
setGoalErr("");
|
||||
try {
|
||||
await api.updateGoal(taskId, g.id, text, editVuln.trim() || undefined);
|
||||
cancelEditGoal();
|
||||
await loadGoals();
|
||||
} catch (e) {
|
||||
setGoalErr(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setGoalBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeGoal = async (g: TaskGoal) => {
|
||||
setGoals((prev) => prev.filter((x) => x.id !== g.id));
|
||||
try {
|
||||
await api.deleteGoal(taskId, g.id);
|
||||
} catch {
|
||||
await loadGoals(); // 删除失败:重新拉取还原
|
||||
}
|
||||
};
|
||||
|
||||
const addScope = async () => {
|
||||
const value = scopeValueInput.trim();
|
||||
if (!value) return;
|
||||
@@ -216,15 +292,17 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
load();
|
||||
void loadScope();
|
||||
void loadTokens();
|
||||
void loadGoals();
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
void loadTokens();
|
||||
void loadGoals();
|
||||
}, 3000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId, loadScope, loadTokens]);
|
||||
}, [taskId, loadScope, loadTokens, loadGoals]);
|
||||
|
||||
const running = intents.filter((i) => i.state === "running");
|
||||
const open = intents.filter((i) => i.state === "open");
|
||||
@@ -264,6 +342,128 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 目标管理:查看/新增/修改/删除本任务的探索目标。新增与修改会通知规划者并复活任务,
|
||||
删除仅通知规划者(不复活)。目标 = 最终可交付/可核验的结果,不是攻击步骤或侦察动作。 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ListChecksIcon className="size-4 text-primary" /> 目标管理
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(最终可核验的目标,共 {goals.length} 条;新增/修改会通知规划者并复活任务)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{/* 新增表单 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
placeholder="新增目标,如『拿到管理员账号的越权访问』"
|
||||
value={goalText}
|
||||
onChange={(e) => setGoalText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-32 text-sm"
|
||||
placeholder="漏洞类(可选)"
|
||||
value={goalVuln}
|
||||
onChange={(e) => setGoalVuln(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={goalBusy || !goalText.trim()} onClick={() => void addGoal()}>
|
||||
<PlusIcon className="size-3.5" /> 添加
|
||||
</Button>
|
||||
{goalErr && <span className="text-xs text-red-500">{goalErr}</span>}
|
||||
</div>
|
||||
{/* 目标列表 */}
|
||||
{goals.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{goals.map((g) =>
|
||||
editingGoalId === g.id ? (
|
||||
<div key={g.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
|
||||
<Input
|
||||
className="h-7 min-w-56 flex-1 text-sm"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveEditGoal(g);
|
||||
if (e.key === "Escape") cancelEditGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-32 text-sm"
|
||||
placeholder="漏洞类(可选)"
|
||||
value={editVuln}
|
||||
onChange={(e) => setEditVuln(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveEditGoal(g);
|
||||
if (e.key === "Escape") cancelEditGoal();
|
||||
}}
|
||||
disabled={goalBusy}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy || !editText.trim()}
|
||||
onClick={() => void saveEditGoal(g)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy}
|
||||
onClick={cancelEditGoal}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div key={g.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
||||
<StatusBadge domain="goal" value={g.state} />
|
||||
<span className="min-w-0 flex-1 break-words">{g.text}</span>
|
||||
{g.vulnclass && (
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||
{g.vulnclass}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy}
|
||||
onClick={() => startEditGoal(g)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
disabled={goalBusy}
|
||||
onClick={() => void removeGoal(g)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">暂无目标,添加后规划者会据此派发探索意图并判定达成。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -56,6 +56,7 @@ import type {
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskGoal,
|
||||
TaskLLMResolutions,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
@@ -256,6 +257,18 @@ export const api = {
|
||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// ---- 目标管理(总览)----
|
||||
// 本任务全部目标(text/vulnclass/state)。
|
||||
taskGoals: (id: string) => get<{ goals: TaskGoal[] }>(`/tasks/${id}/goals`),
|
||||
// 人工新增目标:写入图谱并通知 planner、复活任务。
|
||||
addGoal: (id: string, text: string, vulnclass?: string) =>
|
||||
post<TaskGoal>(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }),
|
||||
// 人工修改目标文本(及 vulnclass):通知 planner「由 old 变为 new」、复活任务。
|
||||
updateGoal: (id: string, goalId: string, text: string, vulnclass?: string) =>
|
||||
patch<TaskGoal>(`/tasks/${id}/goals/${goalId}`, { text, vulnclass: vulnclass ?? "" }),
|
||||
// 人工删除目标(硬删除):通知 planner,删除不复活任务。
|
||||
deleteGoal: (id: string, goalId: string) => del<{ ok: boolean }>(`/tasks/${id}/goals/${goalId}`),
|
||||
|
||||
// 本任务测试范围列表(含继承自来源任务的范围)。
|
||||
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
|
||||
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
|
||||
|
||||
@@ -299,6 +299,16 @@ export interface TaskNode {
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
|
||||
export interface TaskGoal {
|
||||
id: string;
|
||||
text: string;
|
||||
vulnclass?: string;
|
||||
state: string; // GoalState
|
||||
origin?: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
// ---- Findings ----
|
||||
export type Severity = "critical" | "high" | "medium" | "low";
|
||||
|
||||
|
||||
Reference in new issue
Block a user