mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Merge pull request #59 from zabcxky-123/codex/fix-task-findings-time-sort
fix(web): 恢复项目发现页按时间排序
This commit is contained in:
11 files changed
+338
-65
No files matched your search
@@ -13,18 +13,16 @@ 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 { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { api } from "@/lib/api";
|
||||
import { filterAndSortFindings } from "@/lib/finding-severity";
|
||||
import { type FindingSortField, filterAndSortFindings } from "@/lib/finding-severity";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import { SEVERITY_FILTER_OPTIONS, statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FindingSortField = "time";
|
||||
|
||||
const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"];
|
||||
const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time", "severity"];
|
||||
const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort";
|
||||
|
||||
function findingLabel(finding: Finding): string {
|
||||
@@ -185,7 +183,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
// 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, query);
|
||||
const items = filterAndSortFindings(taskFindings, severity, sortPreference, query);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -204,6 +202,42 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Select
|
||||
value={sortPreference.field}
|
||||
onValueChange={(field) => {
|
||||
if (field === "time" || field === "severity") {
|
||||
setSortPreference((current) => ({ ...current, field }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-36" aria-label="发现排序方式">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="time">按时间排序</SelectItem>
|
||||
<SelectItem value="severity">按严重度排序</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`${sortPreference.field === "severity" ? "同等级" : "全部"}发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
...current,
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDownIcon aria-hidden="true" />
|
||||
)}
|
||||
{sortPreference.field === "severity" ? "同等级" : ""}
|
||||
{sortPreference.direction === "asc" ? "最早在前" : "最新在前"}
|
||||
</Button>
|
||||
<form
|
||||
className="flex w-full items-center gap-2 sm:w-auto"
|
||||
onSubmit={(event) => {
|
||||
@@ -233,7 +267,8 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
task: taskId,
|
||||
severity: severity === "all" ? undefined : severity,
|
||||
query: query || undefined,
|
||||
sort: "severity",
|
||||
sort: sortPreference.field,
|
||||
timeDirection: sortPreference.direction,
|
||||
}}
|
||||
contextTask={taskId}
|
||||
/>
|
||||
@@ -252,7 +287,8 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
task: source,
|
||||
severity: severity === "all" ? undefined : severity,
|
||||
query: query || undefined,
|
||||
sort: "severity",
|
||||
sort: sortPreference.field,
|
||||
timeDirection: sortPreference.direction,
|
||||
}}
|
||||
contextTask={taskId}
|
||||
readOnly
|
||||
@@ -264,25 +300,17 @@ 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>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
||||
aria-label={`同等级发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
field: "time",
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span>同等级发现时间</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
漏洞({sortPreference.field === "severity" ? "按严重度排序" : "按时间排序"})
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>{sortPreference.field === "severity" ? "同等级发现时间" : "发现时间"}</span>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{items.map((f) => (
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
|
||||
@@ -242,6 +242,7 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
|
||||
if (q.task && q.task !== "all") p.set("task_id", q.task);
|
||||
if (q.query?.trim()) p.set("q", q.query.trim());
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
if (q.timeDirection) p.set("time_direction", q.timeDirection);
|
||||
if (q.assetScope) p.set("asset_scope", q.assetScope);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -21,25 +21,59 @@ const rows = [
|
||||
finding("5", "high", "2026-10-26T12:00:00Z", { inherited: true, source_task_id: "source" }),
|
||||
];
|
||||
|
||||
test("time descending sorts across severity levels instead of promoting older critical findings", () => {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, "all", { field: "time", direction: "desc" }).map((row) => row.id),
|
||||
["5", "4", "1", "2", "3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("time ascending can place an older low-severity finding before a critical finding", () => {
|
||||
const oldest = finding("6", "low", "2026-10-23T12:00:00Z");
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings([...rows, oldest], "all", { field: "time", direction: "asc" }).map((row) => row.id),
|
||||
["6", "3", "2", "5", "4", "1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("time sorting preserves severity/search filters and inherited records without mutating the snapshot", () => {
|
||||
const snapshot = Object.freeze(rows.map((row) => Object.freeze({ ...row, evidence: "Shared evidence" })));
|
||||
for (const direction of ["asc", "desc"]) {
|
||||
const result = filterAndSortFindings(snapshot, "high", { field: "time", direction }, " EVIDENCE ");
|
||||
assert.deepEqual(
|
||||
result.map((row) => row.id),
|
||||
direction === "asc" ? ["2", "5"] : ["5", "2"],
|
||||
);
|
||||
assert.equal(result.find((row) => row.id === "5").source_task_id, "source");
|
||||
}
|
||||
assert.deepEqual(
|
||||
snapshot.map((row) => row.id),
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
});
|
||||
|
||||
test("all keeps every report and orders severity before newest time", () => {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, "all", "desc").map((row) => row.id),
|
||||
filterAndSortFindings(rows, "all", { field: "severity", direction: "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");
|
||||
const items = filterAndSortFindings(rows, severity, { field: "severity", direction: "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");
|
||||
assert.equal(
|
||||
filterAndSortFindings(rows, "high", { field: "severity", direction: "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),
|
||||
filterAndSortFindings(rows, "all", { field: "severity", direction: "asc" }).map((row) => row.id),
|
||||
["3", "2", "5", "4", "1"],
|
||||
);
|
||||
});
|
||||
@@ -48,29 +82,32 @@ 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),
|
||||
filterAndSortFindings(tied, "all", { field: "severity", 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"), []);
|
||||
assert.deepEqual(filterAndSortFindings([], "all", { field: "severity", direction: "desc" }), []);
|
||||
assert.deepEqual(filterAndSortFindings([rows[0]], "critical", { field: "severity", direction: "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.notEqual(filterAndSortFindings(snapshot, "all", { field: "severity", direction: "desc" }), snapshot);
|
||||
filterAndSortFindings(snapshot, "high", { field: "severity", direction: "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]);
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings([rows[0], match], "all", { field: "severity", direction: "desc" }, " nEEdle "),
|
||||
[match],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,8 +118,11 @@ test("unmatched keywords and missing searchable fields return no matches", () =>
|
||||
summary: undefined,
|
||||
evidence: undefined,
|
||||
});
|
||||
assert.deepEqual(filterAndSortFindings([...rows, missing], "all", "desc", "needle"), []);
|
||||
assert.deepEqual(filterAndSortFindings([], "all", "desc", "needle"), []);
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings([...rows, missing], "all", { field: "severity", direction: "desc" }, "needle"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(filterAndSortFindings([], "all", { field: "severity", direction: "desc" }, "needle"), []);
|
||||
});
|
||||
|
||||
test("undefined, empty and whitespace keywords preserve severity filtering and sorting", () => {
|
||||
@@ -90,8 +130,8 @@ test("undefined, empty and whitespace keywords preserve severity filtering and s
|
||||
for (const severity of ["all", "high"]) {
|
||||
for (const direction of ["asc", "desc"]) {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, severity, direction, query),
|
||||
filterAndSortFindings(rows, severity, direction),
|
||||
filterAndSortFindings(rows, severity, { field: "severity", direction }, query),
|
||||
filterAndSortFindings(rows, severity, { field: "severity", direction }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,12 +141,12 @@ test("undefined, empty and whitespace keywords preserve severity filtering and s
|
||||
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),
|
||||
filterAndSortFindings(matches, "high", { field: "severity", direction: "desc" }, "KEYWORD").map((row) => row.id),
|
||||
["5", "2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(matches, "high", "asc", "keyword").map((row) => row.id),
|
||||
filterAndSortFindings(matches, "high", { field: "severity", direction: "asc" }, "keyword").map((row) => row.id),
|
||||
["2", "5"],
|
||||
);
|
||||
assert.deepEqual(filterAndSortFindings(matches, "high", "desc", "absent"), []);
|
||||
assert.deepEqual(filterAndSortFindings(matches, "high", { field: "severity", direction: "desc" }, "absent"), []);
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { SortPreference } from "./sort-preference";
|
||||
import type { Finding, Severity } from "./types";
|
||||
|
||||
export type FindingSortField = "time" | "severity";
|
||||
|
||||
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",
|
||||
sort: SortPreference<FindingSortField>,
|
||||
query?: string,
|
||||
): Finding[] {
|
||||
const keyword = query?.trim().toLowerCase() ?? "";
|
||||
@@ -20,9 +23,9 @@ export function filterAndSortFindings(
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const severityCompared = (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0);
|
||||
if (severityCompared !== 0) return severityCompared;
|
||||
if (sort.field === "severity" && severityCompared !== 0) return severityCompared;
|
||||
const timeCompared = Date.parse(a.ts) - Date.parse(b.ts);
|
||||
if (timeCompared !== 0) return timeDirection === "asc" ? timeCompared : -timeCompared;
|
||||
if (timeCompared !== 0) return sort.direction === "asc" ? timeCompared : -timeCompared;
|
||||
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isCompanyScopeKind,
|
||||
normalizeCompanyScopeValue,
|
||||
} from "../company-scope";
|
||||
import { filterAndSortFindings } from "../finding-severity";
|
||||
import type {
|
||||
Activity,
|
||||
ArchiveBatchItem,
|
||||
@@ -1962,11 +1963,13 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/finding-case-review" && m === "POST")
|
||||
throw new Error("演示模式不执行 Reporter 整理,请在已配置模型的实例中使用");
|
||||
if (path === "/exploration/finding-cases" && m === "GET") {
|
||||
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
|
||||
const ranks = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(q.get("sort") === "severity" ? ranks[b.severity] - ranks[a.severity] : 0) || +new Date(b.ts) - +new Date(a.ts),
|
||||
const list = filterAndSortFindings(
|
||||
mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope")),
|
||||
"all",
|
||||
{
|
||||
field: q.get("sort") === "severity" ? "severity" : "time",
|
||||
direction: q.get("time_direction") === "asc" ? "asc" : "desc",
|
||||
},
|
||||
);
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("limit")) || 20));
|
||||
|
||||
@@ -602,6 +602,7 @@ export interface FindingQuery {
|
||||
task?: string; // 任务 id;"all"/空 = 不按任务筛选
|
||||
query?: string;
|
||||
sort?: "severity" | "time";
|
||||
timeDirection?: "asc" | "desc"; // Missing = newest first; secondary to severity when sort="severity".
|
||||
// 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。
|
||||
assetScope?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user