归档
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}`}
|
||||
|
||||
Reference in new issue
Block a user