Merge pull request #51 from RuoJi6/feat/findings-retest-filter-search

feat(web): 任务发现/复测严重度筛选与按钮搜索
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-25 19:23:10 -07:00
commit c440c9d3a3
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"
+44
View File
@@ -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"), []);
});
+10 -1
View File
@@ -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;