归档
This commit is contained in:
1 parent
594436fddd
commit
9da521ee4e
31 files changed
+5768
-267
No files matched your search
@@ -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
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user