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}`}