This commit is contained in:
pphua committed 2026-08-28 12:56:29 +08:00
1 parent 594436fddd
commit 9da521ee4e
31 files changed
+5768 -267

No files matched your search

+80 -5
View File
@@ -13,6 +13,7 @@ import {
FileTextIcon,
FlaskConicalIcon,
InfoIcon,
SearchIcon,
ShieldAlertIcon,
Trash2Icon,
TriangleAlertIcon,
@@ -47,6 +48,7 @@ import {
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -55,6 +57,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { api } from "@/lib/api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -73,6 +76,7 @@ const FINDING_STATUSES: FindingStatus[] = [
];
const UNASSIGNED_TASK = "__unassigned__";
const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
interface GroupFindingsState {
items: Finding[];
@@ -125,6 +129,8 @@ export default function FindingsPage() {
const [vulnclass, setVulnclass] = React.useState<string>("all");
const [task, setTask] = React.useState<string>("all");
const [sort, setSort] = React.useState<"severity" | "time">("severity");
const [search, setSearch] = React.useState("");
const [query, setQuery] = React.useState("");
const [expanded, setExpanded] = React.useState<string | null>(null);
const [groups, setGroups] = React.useState<FindingGroup[]>([]);
const [groupTotal, setGroupTotal] = React.useState(0);
@@ -132,15 +138,54 @@ export default function FindingsPage() {
const [groupFindings, setGroupFindings] = React.useState<Record<string, GroupFindingsState>>({});
const [total, setTotal] = React.useState(0);
const [stats, setStats] = React.useState<FindingStats>(EMPTY_STATS);
const [statsLoaded, setStatsLoaded] = React.useState(false);
const [preferencesHydrated, setPreferencesHydrated] = React.useState(false);
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(10);
const [deepenFinding, setDeepenFinding] = React.useState<Finding | null>(null);
const [deepenDescription, setDeepenDescription] = React.useState("");
const [deepening, setDeepening] = React.useState(false);
const filterFingerprint = `${severity}:${status}:${vulnclass}:${task}:${sort}`;
const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]);
const activeFilterFingerprint = React.useRef(filterFingerprint);
activeFilterFingerprint.current = filterFingerprint;
React.useEffect(() => {
const raw = getLocalStorageValue(FINDING_LIST_PREFERENCE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw) as {
severity?: unknown;
status?: unknown;
vulnclass?: unknown;
task?: unknown;
sort?: unknown;
};
if (parsed.severity === "all" || SEVERITIES.includes(parsed.severity as Severity)) {
setSeverity(parsed.severity as "all" | Severity);
}
if (parsed.status === "all" || FINDING_STATUSES.includes(parsed.status as FindingStatus)) {
setStatus(parsed.status as "all" | FindingStatus);
}
if (typeof parsed.vulnclass === "string" && parsed.vulnclass) setVulnclass(parsed.vulnclass);
if (typeof parsed.task === "string" && parsed.task) setTask(parsed.task);
if (parsed.sort === "severity" || parsed.sort === "time") setSort(parsed.sort);
} catch {
// Ignore malformed or legacy preferences and retain the defaults.
}
}
setPreferencesHydrated(true);
}, []);
React.useEffect(() => {
if (!preferencesHydrated) return;
setLocalStorageValue(FINDING_LIST_PREFERENCE_KEY, JSON.stringify({ severity, status, vulnclass, task, sort }));
}, [preferencesHydrated, severity, sort, status, task, vulnclass]);
React.useEffect(() => {
const timer = window.setTimeout(() => setQuery(search.trim()), 300);
return () => window.clearTimeout(timer);
}, [search]);
const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => {
setGroupFindings((current) => {
const next: Record<string, GroupFindingsState> = {};
@@ -191,7 +236,7 @@ export default function FindingsPage() {
await api.exportFindings({
format: exportFormat,
scope: exportScope,
filters: { severity, status, vulnclass, task, sort },
filters: { severity, status, vulnclass, task, query, sort },
ids: [...selectedIds],
});
setExportOpen(false);
@@ -224,6 +269,7 @@ export default function FindingsPage() {
status,
vulnclass,
task,
query,
sort,
});
if (request !== groupsRequest.current || activeFilterFingerprint.current !== requestFilter) return;
@@ -233,7 +279,7 @@ export default function FindingsPage() {
} catch {
// Polling keeps the last successful snapshot visible.
}
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, sort]);
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
const loadGroup = React.useCallback(
async (key: string, groupPage: number, groupPageSize: number) => {
@@ -260,6 +306,7 @@ export default function FindingsPage() {
status,
vulnclass,
task: key,
query,
sort,
});
if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return;
@@ -291,7 +338,7 @@ export default function FindingsPage() {
}));
}
},
[filterFingerprint, severity, status, vulnclass, sort],
[filterFingerprint, severity, status, vulnclass, query, sort],
);
React.useEffect(() => {
@@ -367,7 +414,10 @@ export default function FindingsPage() {
api
.findingStats()
.then((s) => {
if (alive) setStats(s);
if (alive) {
setStats(s);
setStatsLoaded(true);
}
})
.catch(() => {
// Keep the previous aggregate snapshot until the next poll.
@@ -381,6 +431,18 @@ export default function FindingsPage() {
};
}, []);
React.useEffect(() => {
if (!statsLoaded) return;
if (vulnclass !== "all" && !stats.vulnclasses.includes(vulnclass)) setVulnclass("all");
if (
task !== "all" &&
task !== UNASSIGNED_TASK &&
!(stats.tasks ?? []).some((option) => String(option.id) === task)
) {
setTask("all");
}
}, [stats, statsLoaded, task, vulnclass]);
// updateStatus optimistically flips one finding's triage state, reverting on error.
const updateStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => {
@@ -566,6 +628,19 @@ export default function FindingsPage() {
</div>
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="w-full sm:w-72">
<InputGroupInput
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="检索漏洞内容"
aria-label="检索漏洞内容"
/>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
<ToggleGroup
type="single"
value={severity}
@@ -3,14 +3,25 @@
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, PauseIcon, PlayIcon } from "lucide-react";
import { ArchiveIcon, ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, PauseIcon, PlayIcon } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
@@ -24,6 +35,7 @@ import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import type { LLMProfile, Task } from "@/lib/types";
@@ -190,6 +202,7 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L
}
function TaskDetailInner() {
const router = useRouter();
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const [task, setTask] = React.useState<Task | null>(null);
@@ -198,6 +211,7 @@ function TaskDetailInner() {
const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [archiving, setArchiving] = React.useState(false);
React.useEffect(() => {
api
@@ -272,6 +286,19 @@ function TaskDetailInner() {
}
}
async function archiveTask() {
if (!task || archiving) return;
setArchiving(true);
try {
await api.archiveTask(task.id);
toast.success("任务已加入归档队列");
router.push("/function/tasks");
} catch (error) {
toast.error(`归档失败:${(error as Error).message}`);
setArchiving(false);
}
}
if (!task) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
@@ -289,6 +316,12 @@ function TaskDetailInner() {
const completed = task.status === "done";
const terminal = ["done", "failed", "timeout"].includes(task.status);
const archiveLifecycleEligible = terminal || paused || task.status === "paused";
const canArchive = archiveLifecycleEligible && !task.archive_blocked_by_task_id;
let archiveDisabledReason = task.queued ? "排队中的任务必须先暂停" : "运行中的任务必须先暂停";
if (archiveLifecycleEligible && task.archive_blocked_by_task_id) {
archiveDisabledReason = `任务被未归档任务 #${task.archive_blocked_by_task_id} 直接继承,请先归档依赖任务`;
}
const engineMode = paused ? "paused" : (task.engine_mode ?? "idle");
let controlVariant: "default" | "secondary" | "outline" = "outline";
let controlIcon = <PauseIcon data-icon="inline-start" />;
@@ -302,6 +335,16 @@ function TaskDetailInner() {
controlIcon = <PlayIcon data-icon="inline-start" />;
controlLabel = "恢复";
}
const archiveTrigger = (
<Button
size="icon-sm"
variant="ghost"
disabled={!canArchive || archiving}
aria-label={canArchive ? "归档任务" : archiveDisabledReason}
>
{archiving ? <Spinner /> : <ArchiveIcon />}
</Button>
);
return (
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
@@ -323,6 +366,31 @@ function TaskDetailInner() {
<Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
<TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
<StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
{canArchive ? (
<AlertDialog>
<AlertDialogTrigger asChild>{archiveTrigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>归档任务 #{task.id}?</AlertDialogTitle>
<AlertDialogDescription>
任务图谱、关联记录、独占资产与流量、工作文件和 LLM
历史将压缩到冷存储。归档完成后可在任务列表的“已归档”页还原。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => void archiveTask()}>确认归档</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{archiveTrigger}</span>
</TooltipTrigger>
<TooltipContent>{archiveDisabledReason}</TooltipContent>
</Tooltip>
)}
<Button size="sm" variant={controlVariant} onClick={togglePause} disabled={terminal}>
{controlIcon}
{controlLabel}
File diff suppressed because it is too large. Load diff
+45 -12
View File
@@ -3,6 +3,8 @@
import * as React from "react";
import {
ArrowDownWideNarrowIcon,
ArrowUpNarrowWideIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
@@ -291,6 +293,7 @@ function HttpCodeBlock({ raw }: { raw: string }) {
// single page, which would only ever list the methods on that page.
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
const PAGE_SIZES = [25, 50, 100, 200];
type HostCountSortDirection = "asc" | "desc";
export default function TrafficPage() {
const [page, setPage] = React.useState(0);
@@ -309,6 +312,7 @@ export default function TrafficPage() {
const [hosts, setHosts] = React.useState<TrafficHost[]>([]); // target picker
const [selectedHosts, setSelectedHosts] = React.useState<string[]>([]); // checked in picker
const [pickerOpen, setPickerOpen] = React.useState(false);
const [hostCountSortDirection, setHostCountSortDirection] = React.useState<HostCountSortDirection>("desc");
const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | null>(null); // null = dialog closed
const [deleting, setDeleting] = React.useState(false);
@@ -367,6 +371,14 @@ export default function TrafficPage() {
// Delete traffic for the current host filter (substring) or the checked
// hosts (exact batch), then refetch.
const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host));
const sortedHosts = React.useMemo(
() =>
[...hosts].sort((a, b) => {
const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count;
return countOrder || a.host.localeCompare(b.host);
}),
[hosts, hostCountSortDirection],
);
const confirmDelete = () => {
setDeleting(true);
@@ -455,28 +467,49 @@ export default function TrafficPage() {
</Button>
</PopoverTrigger>
<PopoverContent
className="w-80 p-0 data-open:animate-none data-closed:animate-none"
className="w-[calc(100vw-2rem)] p-0 data-open:animate-none data-closed:animate-none sm:w-80"
align="start"
collisionPadding={16}
>
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">按目标批量删除</span>
{hosts.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
>
{allSelected ? "取消全选" : "全选"}
</Button>
)}
<div className="flex items-center gap-1">
{hosts.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setHostCountSortDirection((current) => (current === "desc" ? "asc" : "desc"))}
aria-label={
hostCountSortDirection === "desc"
? "数据包数量当前倒序,点击切换为正序"
: "数据包数量当前正序,点击切换为倒序"
}
>
{hostCountSortDirection === "desc" ? <ArrowDownWideNarrowIcon /> : <ArrowUpNarrowWideIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"}</TooltipContent>
</Tooltip>
)}
{hosts.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
>
{allSelected ? "取消全选" : "全选"}
</Button>
)}
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{hosts.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
) : (
hosts.map((h, index) => (
sortedHosts.map((h, index) => (
<label
key={h.host}
htmlFor={`traffic-host-${index}`}
+24 -1
View File
@@ -129,6 +129,27 @@ function groupSteps(steps: Activity[], chat: boolean): Group[] {
const kindLabel = (k: string) => (k === "thinking" ? "推理" : k === "result" ? "总结" : "说明");
function ActivityTime({ ts }: { ts: string }) {
const date = new Date(ts);
if (!ts || Number.isNaN(date.getTime())) return null;
return (
<time
dateTime={date.toISOString()}
title={date.toLocaleString("zh-CN")}
className="text-[10px] text-muted-foreground tabular-nums"
suppressHydrationWarning
>
{date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}
</time>
);
}
// toolInputText renders a tool_use input for display. For Bash it pulls the shell
// command out of the raw input JSON ({"command":…,"description":…}) so the UI shows
// the command itself instead of JSON; other tools fall back to the raw text.
@@ -520,6 +541,7 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
{text}
</div>
)}
<ActivityTime ts={step.ts} />
</div>
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="size-3.5 text-primary" />
@@ -549,7 +571,7 @@ function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: num
};
}, [inView, step.seq, getDetail, step.summary]);
return (
<div ref={ref} className="mb-2 mt-1 flex">
<div ref={ref} className="mb-2 mt-1 flex min-w-0 flex-col gap-1">
<div
className={
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
@@ -562,6 +584,7 @@ function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: num
<Markdown text={full ?? step.summary} />
)}
</div>
<ActivityTime ts={step.ts} />
</div>
);
}
+27
View File
@@ -10,6 +10,7 @@ import type {
Agent,
AgentDetail,
AgentTrigger,
ArchiveBatchItem,
Asset,
Audit,
BatchCategoryItem,
@@ -59,6 +60,8 @@ import type {
SSTask,
Stats,
Task,
TaskArchive,
TaskArchivePage,
TaskAssetMutation,
TaskAssetScopeMutation,
TaskCategory,
@@ -253,6 +256,27 @@ export const api = {
post<{ id: string; paused: boolean; queued: boolean; status: string }>(`/tasks/${id}/control`, { action }),
controlTasksBatch: (taskIds: string[], action: "pause" | "resume") =>
post<{ items: BatchControlItem[] }>("/tasks/control/batch", { task_ids: taskIds, action }),
taskArchives: (input?: { page?: number; size?: number; q?: string; state?: string }) => {
const query = new URLSearchParams();
query.set("page", String(input?.page ?? 1));
query.set("size", String(input?.size ?? 20));
if (input?.q) query.set("q", input.q);
if (input?.state) query.set("state", input.state);
return get<TaskArchivePage>(`/task-archives?${query.toString()}`).then((response) => ({
...response,
items: arr(response.items),
}));
},
taskArchive: (id: number) => get<TaskArchive>(`/task-archives/${id}`),
archiveTask: (id: string) => post<TaskArchive>(`/tasks/${id}/archive`),
archiveTasks: (taskIds: string[]) =>
post<{ items: ArchiveBatchItem[] }>("/tasks/archive/batch", { task_ids: taskIds }),
restoreTaskArchive: (id: number) => post<TaskArchive>(`/task-archives/${id}/restore`),
restoreTaskArchives: (archiveIds: number[]) =>
post<{ items: ArchiveBatchItem[] }>("/task-archives/restore/batch", { archive_ids: archiveIds }),
deleteTaskArchive: (id: number) => del<TaskArchive>(`/task-archives/${id}`),
deleteTaskArchives: (archiveIds: number[]) =>
post<{ items: ArchiveBatchItem[] }>("/task-archives/delete/batch", { archive_ids: archiveIds }),
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel", reason?: string) =>
post<{
id: number;
@@ -429,6 +453,7 @@ export const api = {
if (q.status && q.status !== "all") p.set("status", q.status);
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
if (q.task && q.task !== "all") p.set("task_id", q.task);
if (q.query?.trim()) p.set("q", q.query.trim());
if (q.sort) p.set("sort", q.sort);
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
},
@@ -438,6 +463,7 @@ export const api = {
if (q.status && q.status !== "all") p.set("status", q.status);
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
if (q.task && q.task !== "all") p.set("task_id", q.task);
if (q.query?.trim()) p.set("q", q.query.trim());
if (q.sort) p.set("sort", q.sort);
return get<FindingGroupsPage>(`/exploration/findings/groups?${p.toString()}`);
},
@@ -459,6 +485,7 @@ export const api = {
if (f.status && f.status !== "all") p.set("status", f.status);
if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass);
if (f.task && f.task !== "all") p.set("task_id", f.task);
if (f.query?.trim()) p.set("q", f.query.trim());
if (f.sort) p.set("sort", f.sort);
}
const token = getToken();
+315 -2
View File
@@ -10,6 +10,7 @@ import {
normalizeCompanyScopeValue,
} from "../company-scope";
import type {
ArchiveBatchItem,
Asset,
BatchControlItem,
Company,
@@ -18,6 +19,7 @@ import type {
IntentAsset,
ScopeRow,
Task,
TaskArchive,
TaskAssetMutation,
TaskAssetScopeMutation,
TaskCategory,
@@ -40,6 +42,15 @@ const mockConversations = structuredClone(D.conversations);
const mockIntents = structuredClone(D.intents);
const mockCompanies = structuredClone(D.companies);
const mockAssets = structuredClone(D.assets);
type MockTaskArchiveSnapshot = {
task: Task;
numericTaskID: number;
assetIDs: number[];
assetSources: Array<[number, MockTaskAssetSource]>;
};
type MockTaskArchive = TaskArchive & { snapshot: MockTaskArchiveSnapshot };
const mockTaskArchives: MockTaskArchive[] = [];
let nextMockTaskArchiveID = 1;
const mockTaskAssetIDs = new Map(D.tasks.map((task, index) => [task.id, index + 1]));
const mockTaskScopes = new Map<string, TaskScopeRow[]>();
type MockTaskAssetSource = Pick<Asset, "task_source" | "task_source_summary" | "task_source_node_id">;
@@ -51,6 +62,195 @@ function mockTaskAssetSourceKey(taskID: string, assetID: number): string {
return JSON.stringify([taskID, assetID]);
}
function publicMockTaskArchive(archive: MockTaskArchive): TaskArchive {
const { snapshot: _snapshot, ...item } = archive;
return structuredClone(item);
}
function mockArchiveTaskID(taskID: string): number {
const numeric = Number(taskID);
if (Number.isSafeInteger(numeric) && numeric > 0) return numeric;
return mockTaskAssetID(taskID) ?? 0;
}
function mockTaskArchiveBlocker(taskID: string): string | undefined {
return mockTasks.find(
(candidate) =>
candidate.id !== taskID &&
(candidate.source_task_ids ?? []).map(String).includes(taskID) &&
!mockTaskArchives.some(
(archive) =>
archive.snapshot.task.id === candidate.id &&
(archive.state === "archive_queued" || archive.state === "archiving"),
),
)?.id;
}
function publicMockTask(task: Task): Task {
const item = structuredClone(task);
const blocker = mockTaskArchiveBlocker(task.id);
if (blocker) item.archive_blocked_by_task_id = blocker;
else delete item.archive_blocked_by_task_id;
return item;
}
function mockArchiveTask(taskID: string): MockTaskArchive {
const task = mockTasks.find((item) => item.id === taskID);
if (!task) throw new Error("任务不存在");
if (!["paused", "done", "failed", "timeout"].includes(task.status) && !task.paused) {
throw new Error(task.queued ? "排队中的任务必须先暂停" : "运行中的任务必须先暂停");
}
const existing = mockTaskArchives.find((item) => item.task_id === mockArchiveTaskID(taskID));
if (existing) throw new Error("任务已经在归档队列中");
const dependent = mockTasks.find(
(candidate) =>
candidate.id !== taskID &&
(candidate.source_task_ids ?? []).map(String).includes(taskID) &&
!mockTaskArchives.some(
(archive) =>
archive.snapshot.task.id === candidate.id &&
(archive.state === "archive_queued" || archive.state === "archiving"),
),
);
if (dependent) throw new Error(`任务被未归档任务 #${dependent.id} 直接继承,暂不能归档`);
const numericTaskID = mockArchiveTaskID(taskID);
const assetIDs = mockAssets.filter((asset) => asset.task_ids.includes(numericTaskID)).map((asset) => asset.id);
const assetSources: Array<[number, MockTaskAssetSource]> = [];
for (const assetID of assetIDs) {
const source = mockTaskAssetSources.get(mockTaskAssetSourceKey(taskID, assetID));
if (source) assetSources.push([assetID, structuredClone(source)]);
}
const findings = mockFindings.filter((finding) => finding.task_id === taskID);
const llmRecords = mockLLMRecords.filter((record) => record.task_id === taskID);
const tokens = task.tokens ?? { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 };
const now = new Date().toISOString();
const archive: MockTaskArchive = {
id: nextMockTaskArchiveID++,
task_id: numericTaskID,
state: "archive_queued",
phase: "等待归档",
progress: 0,
format_version: 1,
original_size: 0,
compressed_size: 0,
task_name: task.name ?? "",
task_description: task.description,
task_goal: task.goal,
original_status: task.status,
category_id: task.category_id,
category_name: task.category_name,
source_task_ids: (task.source_task_ids ?? []).map(mockArchiveTaskID).filter((id) => id > 0),
remaining_timeout_seconds: 0,
data_counts: {
assets: assetIDs.length,
findings: findings.length,
llm_records: llmRecords.length,
},
aggregate_stats: {
tokens: {
calls: llmRecords.length,
input_tokens: tokens.input_tokens,
output_tokens: tokens.output_tokens,
cache_read_tokens: tokens.cache_read_tokens,
cache_write_tokens: tokens.cache_write_tokens,
},
findings: findings.reduce<Record<string, number>>((counts, finding) => {
counts[finding.severity] = (counts[finding.severity] ?? 0) + 1;
return counts;
}, {}),
},
requested_at: now,
created_at: now,
updated_at: now,
snapshot: { task: structuredClone(task), numericTaskID, assetIDs, assetSources },
};
mockTaskArchives.unshift(archive);
setTimeout(() => {
if (archive.state !== "archive_queued") return;
archive.state = "archiving";
archive.phase = "压缩任务数据";
archive.progress = 55;
archive.updated_at = new Date().toISOString();
}, 100);
setTimeout(() => {
if (archive.state !== "archiving" && archive.state !== "archive_queued") return;
archive.state = "ready";
archive.phase = "归档完成";
archive.progress = 100;
archive.archived_at = new Date().toISOString();
archive.updated_at = archive.archived_at;
archive.original_size = Math.max(4096, JSON.stringify(archive.snapshot).length * 4);
archive.compressed_size = Math.max(1024, Math.round(archive.original_size * 0.32));
const index = mockTasks.findIndex((item) => item.id === taskID);
if (index >= 0) mockTasks.splice(index, 1);
for (const asset of mockAssets) asset.task_ids = asset.task_ids.filter((id) => id !== numericTaskID);
deleteMockTaskAssetSources(taskID);
if (mockActiveTask === taskID) {
mockActiveTask = mockTasks[0]?.id ?? "";
for (const item of mockTasks) item.active = item.id === mockActiveTask;
}
}, 500);
return archive;
}
function mockRestoreArchive(archive: MockTaskArchive): void {
if (archive.state !== "ready" && archive.state !== "restore_failed") throw new Error("当前归档状态不可还原");
archive.state = "restore_queued";
archive.phase = "等待还原";
archive.progress = 0;
archive.error = undefined;
archive.updated_at = new Date().toISOString();
setTimeout(() => {
if (archive.state !== "restore_queued") return;
archive.state = "restoring";
archive.phase = "恢复任务数据";
archive.progress = 60;
archive.updated_at = new Date().toISOString();
}, 100);
setTimeout(() => {
if (archive.state !== "restoring" && archive.state !== "restore_queued") return;
const restored = structuredClone(archive.snapshot.task);
restored.active = false;
restored.queued = false;
if (!mockTasks.some((task) => task.id === restored.id)) mockTasks.push(restored);
mockTaskAssetIDs.set(restored.id, archive.snapshot.numericTaskID);
for (const assetID of archive.snapshot.assetIDs) {
const asset = mockAssets.find((candidate) => candidate.id === assetID);
if (asset && !asset.task_ids.includes(archive.snapshot.numericTaskID)) {
asset.task_ids.push(archive.snapshot.numericTaskID);
}
}
for (const [assetID, source] of archive.snapshot.assetSources) setMockTaskAssetSource(restored.id, assetID, source);
const index = mockTaskArchives.indexOf(archive);
if (index >= 0) mockTaskArchives.splice(index, 1);
sortMockTasks();
}, 500);
}
function mockDeleteArchive(archive: MockTaskArchive): void {
if (archive.state !== "ready" && archive.state !== "delete_failed") throw new Error("当前归档状态不可永久删除");
const dependent = mockTaskArchives.find(
(candidate) => candidate.id !== archive.id && candidate.source_task_ids.includes(archive.task_id),
);
if (dependent) throw new Error(`归档仍被任务 #${dependent.task_id} 依赖,无法永久删除`);
archive.state = "delete_queued";
archive.phase = "等待永久删除";
archive.progress = 0;
archive.error = undefined;
archive.updated_at = new Date().toISOString();
setTimeout(() => {
if (archive.state !== "delete_queued") return;
archive.state = "deleting";
archive.phase = "删除归档包";
archive.progress = 70;
}, 100);
setTimeout(() => {
const index = mockTaskArchives.indexOf(archive);
if (index >= 0) mockTaskArchives.splice(index, 1);
}, 450);
}
function setMockTaskAssetSource(taskID: string, assetID: number, source: MockTaskAssetSource) {
mockTaskAssetSources.set(mockTaskAssetSourceKey(taskID, assetID), source);
}
@@ -98,6 +298,16 @@ function mockAssetMatchesDSL(asset: Asset, dsl: string): boolean {
return query.split(/\s+/).every((term) => haystack.includes(term));
}
function mockFindingMatchesQuery(finding: (typeof mockFindings)[number], query: string | null): boolean {
const needle = query?.trim().toLowerCase();
if (!needle) return true;
return [finding.name, finding.vulnclass, finding.summary, finding.evidence, finding.report].some((value) =>
String(value ?? "")
.toLowerCase()
.includes(needle),
);
}
function mockTaskCategorySnapshot(): TaskCategory[] {
return mockTaskCategories.map((category) => ({
...category,
@@ -303,10 +513,111 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/auth/login" || path === "/auth/init") return { token: "mock-demo" };
if (path === "/auth/change-password") return { ok: true };
// ── task cold archives ──
if (path === "/task-archives" && m === "GET") {
const page = Math.max(1, Number(q.get("page")) || 1);
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
const query = (q.get("q") ?? "").trim().toLowerCase();
const state = (q.get("state") ?? "").trim();
const filtered = mockTaskArchives.filter((archive) => {
if (state && archive.state !== state) return false;
if (!query) return true;
return [archive.task_id, archive.task_name, archive.task_description].some((value) =>
String(value ?? "")
.toLowerCase()
.includes(query),
);
});
const offset = (page - 1) * size;
return {
items: filtered.slice(offset, offset + size).map(publicMockTaskArchive),
total: filtered.length,
page,
size,
};
}
if (seg[0] === "task-archives" && seg.length === 2 && m === "GET") {
const archive = mockTaskArchives.find((item) => item.id === Number(seg[1]));
if (!archive) throw new Error("归档不存在");
return publicMockTaskArchive(archive);
}
if (seg[0] === "task-archives" && seg[2] === "restore" && seg.length === 3 && m === "POST") {
const archive = mockTaskArchives.find((item) => item.id === Number(seg[1]));
if (!archive) throw new Error("归档不存在");
mockRestoreArchive(archive);
return publicMockTaskArchive(archive);
}
if (seg[0] === "task-archives" && seg.length === 2 && m === "DELETE") {
const archive = mockTaskArchives.find((item) => item.id === Number(seg[1]));
if (!archive) throw new Error("归档不存在");
mockDeleteArchive(archive);
return publicMockTaskArchive(archive);
}
if (path === "/task-archives/restore/batch" && m === "POST") {
const ids = bodyIDs(b.archive_ids).map(Number);
const items = ids.map<ArchiveBatchItem>((id) => {
const archive = mockTaskArchives.find((item) => item.id === id);
if (!archive) return { id: String(id), archive_id: id, ok: false, queued: false, error: "归档不存在" };
try {
mockRestoreArchive(archive);
return { id: String(id), archive_id: id, ok: true, queued: true };
} catch (error) {
return { id: String(id), archive_id: id, ok: false, queued: false, error: (error as Error).message };
}
});
return { items };
}
if (path === "/task-archives/delete/batch" && m === "POST") {
const ids = bodyIDs(b.archive_ids).map(Number);
const items = ids.map<ArchiveBatchItem>((id) => {
const archive = mockTaskArchives.find((item) => item.id === id);
if (!archive) return { id: String(id), archive_id: id, ok: false, queued: false, error: "归档不存在" };
try {
mockDeleteArchive(archive);
return { id: String(id), archive_id: id, ok: true, queued: true };
} catch (error) {
return { id: String(id), archive_id: id, ok: false, queued: false, error: (error as Error).message };
}
});
return { items };
}
if (path === "/tasks/archive/batch" && m === "POST") {
const requested = bodyIDs(b.task_ids);
const selected = new Set(requested);
const ordered: string[] = [];
const visiting = new Set<string>();
const visited = new Set<string>();
const visit = (id: string) => {
if (visited.has(id) || visiting.has(id)) return;
visiting.add(id);
for (const candidate of mockTasks) {
if (!selected.has(candidate.id) || !(candidate.source_task_ids ?? []).map(String).includes(id)) continue;
visit(candidate.id);
}
visiting.delete(id);
visited.add(id);
ordered.push(id);
};
for (const id of requested) visit(id);
const byID = new Map<string, ArchiveBatchItem>();
for (const id of ordered) {
try {
const archive = mockArchiveTask(id);
byID.set(id, { id, archive_id: archive.id, ok: true, queued: true });
} catch (error) {
byID.set(id, { id, ok: false, queued: false, error: (error as Error).message });
}
}
return { items: requested.map((id) => byID.get(id) ?? { id, ok: false, queued: false, error: "任务不存在" }) };
}
if (seg[0] === "tasks" && seg[2] === "archive" && seg.length === 3 && m === "POST") {
return publicMockTaskArchive(mockArchiveTask(seg[1]));
}
// ── tasks ──
if (path === "/tasks" && m === "GET") {
sortMockTasks();
return { tasks: mockTasks, active: mockActiveTask };
return { tasks: mockTasks.map(publicMockTask), active: mockActiveTask };
}
if (path === "/tasks" && m === "POST") {
let suffix = 1;
@@ -493,7 +804,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (seg[0] === "tasks" && seg.length === 2 && m === "GET") {
const task = mockTasks.find((item) => item.id === seg[1]);
if (!task) throw new Error("任务不存在");
return structuredClone(task);
return publicMockTask(task);
}
if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") {
const task = mockTasks.find((item) => item.id === seg[1]);
@@ -998,6 +1309,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const filterStatus = q.get("status");
const filterVulnclass = q.get("vulnclass");
const filterTask = q.get("task_id");
list = list.filter((finding) => mockFindingMatchesQuery(finding, q.get("q")));
if (filterSeverity) list = list.filter((finding) => finding.severity === filterSeverity);
if (filterStatus) list = list.filter((finding) => finding.status === filterStatus);
if (filterVulnclass) list = list.filter((finding) => finding.vulnclass === filterVulnclass);
@@ -1108,6 +1420,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const fStatus = q.get("status");
const fVuln = q.get("vulnclass");
const fTask = q.get("task_id");
list = list.filter((finding) => mockFindingMatchesQuery(finding, q.get("q")));
if (fSev) list = list.filter((f) => f.severity === fSev);
if (fStatus) list = list.filter((f) => f.status === fStatus);
if (fVuln) list = list.filter((f) => f.vulnclass === fVuln);
+70
View File
@@ -35,6 +35,7 @@ export interface Task {
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
llm_failover_reason?: string;
source_task_ids?: string[]; // directly related tasks inherited as read-only context
archive_blocked_by_task_id?: string; // live direct dependent that must be archived first
company_ids?: number[]; // associated company scopes; current company assets join the task at creation
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
}
@@ -75,6 +76,74 @@ export interface DeleteTaskResult {
cleanup_warning?: string;
}
export type TaskArchiveState =
| "archive_queued"
| "archiving"
| "archive_failed"
| "ready"
| "restore_queued"
| "restoring"
| "restore_failed"
| "delete_queued"
| "deleting"
| "delete_failed";
export interface TaskArchiveTokenStats {
calls?: number;
input_tokens?: number;
output_tokens?: number;
cache_read_tokens?: number;
cache_write_tokens?: number;
}
export interface TaskArchive {
id: number;
task_id: number;
state: TaskArchiveState;
phase: string;
progress: number;
error?: string;
warnings?: string[];
format_version: number;
sha256?: string;
original_size: number;
compressed_size: number;
task_name: string;
task_description: string;
task_goal: string;
original_status: TaskStatus;
category_id?: number;
category_name?: string;
source_task_ids: number[];
remaining_timeout_seconds: number;
data_counts: Record<string, number>;
aggregate_stats: {
tokens?: TaskArchiveTokenStats;
skills?: Record<string, number>;
tools?: Record<string, number>;
findings?: Record<string, number>;
};
archived_at?: string;
requested_at: string;
created_at: string;
updated_at: string;
}
export interface TaskArchivePage {
items: TaskArchive[];
total: number;
page: number;
size: number;
}
export interface ArchiveBatchItem {
id: string;
archive_id?: number;
ok: boolean;
queued: boolean;
error?: string;
}
// ---- Asset graph (global, shared across tasks) ----
export type AssetType =
| "company"
@@ -477,6 +546,7 @@ export interface FindingQuery {
status?: "all" | FindingStatus;
vulnclass?: string;
task?: string; // 任务 id;"all"/空 = 不按任务筛选
query?: string;
sort?: "severity" | "time";
}