mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(web): 任务发现 tab 支持严重度筛选与排序
对齐全局发现页:共用 severity chip、Case 列表 sort=severity, 原始上报列表前端按严重度过滤并排序。
This commit is contained in:
1 parent
620ca5d96c
commit
0ecaea2fb2
5 files changed
+134
-26
No files matched your search
@@ -42,7 +42,7 @@ 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 { SEVERITY_FILTER_OPTIONS, statusMeta } from "@/lib/status";
|
||||
import type {
|
||||
ActiveFindingRetest,
|
||||
Finding,
|
||||
@@ -918,15 +918,7 @@ export default function FindingsPage() {
|
||||
size="sm"
|
||||
spacing={0}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["all", "全部"],
|
||||
["critical", "严重"],
|
||||
["high", "高危"],
|
||||
["medium", "中危"],
|
||||
["low", "低危"],
|
||||
] as const
|
||||
).map(([val, label]) => (
|
||||
{SEVERITY_FILTER_OPTIONS.map(([val, label]) => (
|
||||
<ToggleGroupItem key={val} value={val} aria-label={`按${label}等级筛选`}>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
|
||||
@@ -12,10 +12,12 @@ import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { api } from "@/lib/api";
|
||||
import { filterAndSortFindings } from "@/lib/finding-severity";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus } from "@/lib/types";
|
||||
import { SEVERITY_FILTER_OPTIONS, statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FindingSortField = "time";
|
||||
@@ -140,6 +142,7 @@ function Row({
|
||||
|
||||
export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [sortPreference, setSortPreference] = useStoredSortPreference(
|
||||
FINDING_SORT_PREFERENCE_KEY,
|
||||
FINDING_SORT_FIELDS,
|
||||
@@ -180,20 +183,32 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const items = findings
|
||||
.filter((f) => f.task_id === taskId || f.inherited)
|
||||
.sort((a, b) => {
|
||||
const compared = Date.parse(a.ts) - Date.parse(b.ts);
|
||||
if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared;
|
||||
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||
});
|
||||
// Discover inherited sources before severity filtering; finding-cases owns folder matching.
|
||||
const taskFindings = findings.filter((f) => f.task_id === taskId || f.inherited);
|
||||
const items = filterAndSortFindings(taskFindings, severity, sortPreference.direction);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FindingCaseList query={{ task: taskId, sort: "time" }} />
|
||||
<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>
|
||||
<FindingCaseList
|
||||
query={{ task: taskId, severity: severity === "all" ? undefined : severity, sort: "severity" }}
|
||||
/>
|
||||
{[
|
||||
...new Set(
|
||||
items
|
||||
taskFindings
|
||||
.filter((f) => f.inherited)
|
||||
.map((f) => f.source_task_id)
|
||||
.filter((id): id is string => !!id),
|
||||
@@ -201,7 +216,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
].map((source) => (
|
||||
<div key={source}>
|
||||
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
|
||||
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
|
||||
<FindingCaseList
|
||||
query={{ task: source, severity: severity === "all" ? undefined : severity, sort: "severity" }}
|
||||
contextTask={taskId}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<details>
|
||||
@@ -209,11 +228,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 flex-1">漏洞</span>
|
||||
<span className="min-w-0 flex-1">漏洞(按严重度排序)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
||||
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
aria-label={`同等级发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
field: "time",
|
||||
@@ -221,7 +240,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span>发现时间</span>
|
||||
<span>同等级发现时间</span>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
) : (
|
||||
@@ -233,7 +252,9 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{severity === "all" ? "本任务及直接关联任务暂无确认发现。" : "当前风险等级暂无原始上报。"}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { filterAndSortFindings } from "./finding-severity.ts";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const finding = (id, severity, ts, extra = {}) => ({
|
||||
id,
|
||||
severity,
|
||||
ts,
|
||||
vulnclass: "XSS",
|
||||
status: "confirmed",
|
||||
summary: id,
|
||||
evidence: "",
|
||||
...extra,
|
||||
});
|
||||
|
||||
const rows = [
|
||||
finding("1", "low", "2026-10-26T12:00:00Z"),
|
||||
finding("2", "high", "2026-10-25T12:00:00Z"),
|
||||
finding("3", "critical", "2026-10-24T12:00:00Z"),
|
||||
finding("4", "medium", "2026-10-26T12:00:00Z"),
|
||||
finding("5", "high", "2026-10-26T12:00:00Z", { inherited: true, source_task_id: "source" }),
|
||||
];
|
||||
|
||||
test("all keeps every report and orders severity before newest time", () => {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, "all", "desc").map((row) => row.id),
|
||||
["3", "5", "2", "4", "1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("each severity filters reports including inherited reports", () => {
|
||||
for (const severity of ["critical", "high", "medium", "low"]) {
|
||||
const items = filterAndSortFindings(rows, severity, "desc");
|
||||
assert.equal(items.length, severity === "high" ? 2 : 1);
|
||||
assert.ok(items.every((row) => row.severity === severity));
|
||||
}
|
||||
assert.equal(filterAndSortFindings(rows, "high", "desc")[0].source_task_id, "source");
|
||||
});
|
||||
|
||||
test("ascending time changes only the order within a severity", () => {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, "all", "asc").map((row) => row.id),
|
||||
["3", "2", "5", "4", "1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("matching severity and timestamp use descending numeric IDs", () => {
|
||||
const tied = [finding("2", "high", rows[0].ts), finding("10", "high", rows[0].ts)];
|
||||
for (const direction of ["asc", "desc"]) {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(tied, "all", direction).map((row) => row.id),
|
||||
["10", "2"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("empty input and a severity without matches return an empty list", () => {
|
||||
assert.deepEqual(filterAndSortFindings([], "all", "desc"), []);
|
||||
assert.deepEqual(filterAndSortFindings([rows[0]], "critical", "desc"), []);
|
||||
});
|
||||
|
||||
test("filtering and sorting preserve the polling snapshot", () => {
|
||||
const snapshot = Object.freeze(rows.map((row) => Object.freeze({ ...row })));
|
||||
const before = [...snapshot];
|
||||
assert.notEqual(filterAndSortFindings(snapshot, "all", "desc"), snapshot);
|
||||
filterAndSortFindings(snapshot, "high", "asc");
|
||||
assert.deepEqual(snapshot, before);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Finding, Severity } from "./types";
|
||||
|
||||
const SEVERITY_RANK: Record<Severity, number> = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
|
||||
export function filterAndSortFindings(
|
||||
findings: readonly Finding[],
|
||||
severity: "all" | Severity,
|
||||
timeDirection: "asc" | "desc",
|
||||
): Finding[] {
|
||||
return findings
|
||||
.filter((finding) => severity === "all" || finding.severity === severity)
|
||||
.sort((a, b) => {
|
||||
const severityCompared = (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0);
|
||||
if (severityCompared !== 0) return severityCompared;
|
||||
const timeCompared = Date.parse(a.ts) - Date.parse(b.ts);
|
||||
if (timeCompared !== 0) return timeDirection === "asc" ? timeCompared : -timeCompared;
|
||||
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
@@ -63,6 +63,14 @@ const severity: Record<string, StatusMeta> = {
|
||||
low: { label: "低危", tone: "slate" },
|
||||
};
|
||||
|
||||
export const SEVERITY_FILTER_OPTIONS = [
|
||||
["all", "全部"],
|
||||
["critical", severity.critical.label],
|
||||
["high", severity.high.label],
|
||||
["medium", severity.medium.label],
|
||||
["low", severity.low.label],
|
||||
] as const;
|
||||
|
||||
const finding: Record<string, StatusMeta> = {
|
||||
pending: { label: "待处理", tone: "amber" },
|
||||
in_progress: { label: "处理中", tone: "blue" },
|
||||
|
||||
Reference in new issue
Block a user