mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(web): 任务发现/复测支持严重度筛选与按钮搜索
- 复测 tab:严重度 chip + sort=severity,左侧列表支持关键词搜索 - 发现/复测:输入框草稿 + 搜索按钮(或 Enter)才应用关键词 - 扩展 filterAndSortFindings 客户端关键词匹配并补充单测
This commit is contained in:
1 parent
28b40bafc8
commit
84b40f3004
4 files changed
+188
-32
No files matched your search
@@ -4,13 +4,15 @@ import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon, SearchIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingCaseList } from "@/components/finding-case-list";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -139,6 +141,8 @@ function Row({
|
||||
export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [sortPreference, setSortPreference] = useStoredSortPreference(
|
||||
FINDING_SORT_PREFERENCE_KEY,
|
||||
FINDING_SORT_FIELDS,
|
||||
@@ -179,28 +183,58 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Discover inherited sources before severity filtering; finding-cases owns folder matching.
|
||||
// Discover inherited sources before filtering; finding-cases owns folder matching.
|
||||
const taskFindings = findings.filter((f) => f.task_id === taskId || f.inherited);
|
||||
const items = filterAndSortFindings(taskFindings, severity, sortPreference.direction);
|
||||
const items = filterAndSortFindings(taskFindings, severity, sortPreference.direction, query);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={severity}
|
||||
onValueChange={(value) => value && setSeverity(value as "all" | Severity)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
>
|
||||
{SEVERITY_FILTER_OPTIONS.map(([val, label]) => (
|
||||
<ToggleGroupItem key={val} value={val} aria-label={`按${label}等级筛选`}>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={severity}
|
||||
onValueChange={(value) => value && setSeverity(value as "all" | Severity)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
>
|
||||
{SEVERITY_FILTER_OPTIONS.map(([val, label]) => (
|
||||
<ToggleGroupItem key={val} value={val} aria-label={`按${label}等级筛选`}>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<form
|
||||
className="flex w-full items-center gap-2 sm:w-auto"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setQuery(search.trim());
|
||||
}}
|
||||
>
|
||||
<InputGroup className="flex-1 sm:w-72">
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="检索漏洞内容"
|
||||
aria-label="检索漏洞内容"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<Button type="submit" size="sm" aria-label="搜索">
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<FindingCaseList
|
||||
query={{ task: taskId, severity: severity === "all" ? undefined : severity, sort: "severity" }}
|
||||
query={{
|
||||
task: taskId,
|
||||
severity: severity === "all" ? undefined : severity,
|
||||
query: query || undefined,
|
||||
sort: "severity",
|
||||
}}
|
||||
contextTask={taskId}
|
||||
/>
|
||||
{[
|
||||
@@ -214,7 +248,12 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
<div key={source}>
|
||||
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
|
||||
<FindingCaseList
|
||||
query={{ task: source, severity: severity === "all" ? undefined : severity, sort: "severity" }}
|
||||
query={{
|
||||
task: source,
|
||||
severity: severity === "all" ? undefined : severity,
|
||||
query: query || undefined,
|
||||
sort: "severity",
|
||||
}}
|
||||
contextTask={taskId}
|
||||
readOnly
|
||||
/>
|
||||
@@ -250,7 +289,9 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{severity === "all" ? "本任务及直接关联任务暂无确认发现。" : "当前风险等级暂无原始上报。"}
|
||||
{severity !== "all" || query
|
||||
? "当前筛选与搜索条件下暂无原始上报。"
|
||||
: "本任务及直接关联任务暂无确认发现。"}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpRightIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react";
|
||||
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
@@ -12,10 +12,13 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingsPage } from "@/lib/types";
|
||||
import { SEVERITY_FILTER_OPTIONS } from "@/lib/status";
|
||||
import type { Finding, FindingsPage, Severity } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -25,12 +28,22 @@ function findingLabel(finding: Finding) {
|
||||
|
||||
export function RetestsTab({ taskId }: { taskId: string }) {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<FindingsPage | null>(null);
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [data, setData] = React.useState<
|
||||
(FindingsPage & { taskId: string; severity: "all" | Severity; query: string }) | null
|
||||
>(null);
|
||||
const [selectedId, setSelectedId] = React.useState("");
|
||||
const [error, setError] = React.useState("");
|
||||
const [refreshVersion, setRefreshVersion] = React.useState(0);
|
||||
const refresh = React.useCallback(() => setRefreshVersion((version) => version + 1), []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Filter changes restart pagination.
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [severity, query]);
|
||||
|
||||
// Only this task's own findings, including those without graph nodes.
|
||||
// Fetch history only for the selection, and wait for each poll to finish.
|
||||
React.useEffect(() => {
|
||||
@@ -38,14 +51,21 @@ export function RetestsTab({ taskId }: { taskId: string }) {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function load() {
|
||||
try {
|
||||
const result = await api.findingsPage({ task: taskId, page, pageSize: PAGE_SIZE, sort: "time" });
|
||||
const result = await api.findingsPage({
|
||||
task: taskId,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
severity: severity === "all" ? undefined : severity,
|
||||
query: query || undefined,
|
||||
sort: "severity",
|
||||
});
|
||||
if (disposed) return;
|
||||
const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
|
||||
if (page > lastPage) {
|
||||
setPage(lastPage);
|
||||
return;
|
||||
}
|
||||
setData(result);
|
||||
setData({ ...result, taskId, severity, query });
|
||||
setSelectedId((current) =>
|
||||
result.items.some((finding) => finding.id === current) ? current : (result.items[0]?.id ?? ""),
|
||||
);
|
||||
@@ -62,14 +82,54 @@ export function RetestsTab({ taskId }: { taskId: string }) {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [taskId, page, refreshVersion]);
|
||||
}, [taskId, page, refreshVersion, severity, query]);
|
||||
|
||||
const loaded = data?.page === page;
|
||||
const loaded = data?.page === page && data.taskId === taskId && data.severity === severity && data.query === query;
|
||||
const findings = loaded ? data.items : [];
|
||||
const selected = findings.find((finding) => finding.id === selectedId);
|
||||
const hasFilters = severity !== "all" || !!query;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={severity}
|
||||
onValueChange={(value) => value && setSeverity(value as "all" | Severity)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
>
|
||||
{SEVERITY_FILTER_OPTIONS.map(([val, label]) => (
|
||||
<ToggleGroupItem key={val} value={val} aria-label={`按${label}等级筛选`}>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<form
|
||||
className="flex w-full items-center gap-2 sm:w-auto"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setQuery(search.trim());
|
||||
}}
|
||||
>
|
||||
<InputGroup className="flex-1 sm:w-72">
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="检索漏洞内容"
|
||||
aria-label="检索漏洞内容"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<Button type="submit" size="sm" aria-label="搜索">
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
@@ -83,7 +143,7 @@ export function RetestsTab({ taskId }: { taskId: string }) {
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[minmax(16rem,22rem)_minmax(0,1fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle>选择漏洞{data ? ` · ${data.total}` : ""}</CardTitle>
|
||||
<CardTitle>选择漏洞{loaded ? ` · ${data.total}` : ""}</CardTitle>
|
||||
<CardDescription>查看本任务漏洞的复测记录,或发起新的复测。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex max-h-[32rem] flex-col overflow-y-auto">
|
||||
@@ -109,13 +169,15 @@ export function RetestsTab({ taskId }: { taskId: string }) {
|
||||
{loaded && findings.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无可复测漏洞</EmptyTitle>
|
||||
<EmptyDescription>本任务发现漏洞后,可在这里手动发起复测。</EmptyDescription>
|
||||
<EmptyTitle>{hasFilters ? "当前筛选暂无漏洞" : "暂无可复测漏洞"}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{hasFilters ? "请调整风险等级或搜索关键词。" : "本任务发现漏洞后,可在这里手动发起复测。"}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
</CardContent>
|
||||
{data && data.total > PAGE_SIZE ? (
|
||||
{loaded && data.total > PAGE_SIZE ? (
|
||||
<CardFooter className="justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -66,3 +66,47 @@ test("filtering and sorting preserve the polling snapshot", () => {
|
||||
filterAndSortFindings(snapshot, "high", "asc");
|
||||
assert.deepEqual(snapshot, before);
|
||||
});
|
||||
|
||||
test("keywords match each searchable field as a trimmed, case-insensitive substring", () => {
|
||||
for (const field of ["name", "vulnclass", "summary", "evidence"]) {
|
||||
const match = finding("match", "high", rows[0].ts, { [field]: "prefix NeEdLe suffix" });
|
||||
assert.deepEqual(filterAndSortFindings([rows[0], match], "all", "desc", " nEEdle "), [match]);
|
||||
}
|
||||
});
|
||||
|
||||
test("unmatched keywords and missing searchable fields return no matches", () => {
|
||||
const missing = finding("missing", "high", rows[0].ts, {
|
||||
name: undefined,
|
||||
vulnclass: undefined,
|
||||
summary: undefined,
|
||||
evidence: undefined,
|
||||
});
|
||||
assert.deepEqual(filterAndSortFindings([...rows, missing], "all", "desc", "needle"), []);
|
||||
assert.deepEqual(filterAndSortFindings([], "all", "desc", "needle"), []);
|
||||
});
|
||||
|
||||
test("undefined, empty and whitespace keywords preserve severity filtering and sorting", () => {
|
||||
for (const query of [undefined, "", " \t\n "]) {
|
||||
for (const severity of ["all", "high"]) {
|
||||
for (const direction of ["asc", "desc"]) {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, severity, direction, query),
|
||||
filterAndSortFindings(rows, severity, direction),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("keywords combine with severity and preserve time ordering for inherited reports", () => {
|
||||
const matches = rows.map((row) => ({ ...row, evidence: "Shared keyword" }));
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(matches, "high", "desc", "KEYWORD").map((row) => row.id),
|
||||
["5", "2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(matches, "high", "asc", "keyword").map((row) => row.id),
|
||||
["2", "5"],
|
||||
);
|
||||
assert.deepEqual(filterAndSortFindings(matches, "high", "desc", "absent"), []);
|
||||
});
|
||||
@@ -6,9 +6,18 @@ export function filterAndSortFindings(
|
||||
findings: readonly Finding[],
|
||||
severity: "all" | Severity,
|
||||
timeDirection: "asc" | "desc",
|
||||
query?: string,
|
||||
): Finding[] {
|
||||
const keyword = query?.trim().toLowerCase() ?? "";
|
||||
return findings
|
||||
.filter((finding) => severity === "all" || finding.severity === severity)
|
||||
.filter(
|
||||
(finding) =>
|
||||
(severity === "all" || finding.severity === severity) &&
|
||||
(!keyword ||
|
||||
[finding.name, finding.vulnclass, finding.summary, finding.evidence].some((value) =>
|
||||
(value ?? "").toLowerCase().includes(keyword),
|
||||
)),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const severityCompared = (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0);
|
||||
if (severityCompared !== 0) return severityCompared;
|
||||
|
||||
Reference in new issue
Block a user