fix(web): restore chronological sorting in task findings

This commit is contained in:
zabcxky-123 committed 2026-10-27 14:25:36 +08:00
1 parent e740334b90
commit 80316252b8
11 files changed
+338 -65

No files matched your search

+1 -1
View File
@@ -123,7 +123,7 @@ func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCa
if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil {
return nil, 0, err
}
order := `e.created DESC,e.fid DESC`
order := `e.created ` + filter.timeOrder() + `,e.fid DESC`
if filter.Sort == "severity" {
order = `e.severity_rank DESC,` + order
}
+171
View File
@@ -0,0 +1,171 @@
package db
import (
"context"
"fmt"
"reflect"
"testing"
"time"
)
func TestFindingGroupTimeSort(t *testing.T) {
d, task, ids := timeSortFixture(t)
other, err := d.CreateTask("newer low severity task", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, other.ID); _ = d.DeleteTask(other.ID) })
marker := fmt.Sprintf("time-sort-%d", time.Now().UnixNano())
if _, err := d.Exec(`UPDATE findings SET vulnclass=$2 WHERE task_id=$1`, task.ID, marker); err != nil {
t.Fatal(err)
}
latest, err := d.AddFinding(other.ID, 0, marker, "latest low", "low", "summary", "evidence", "worker", nil)
if err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-28T12:00:00Z' WHERE id=$1`, latest); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
field, direction string
want int64
}{
{"time", "desc", other.ID},
{"time", "asc", task.ID},
{"severity", "desc", task.ID},
{"severity", "asc", task.ID},
} {
groups, total, reports, err := d.ListFindingGroups(FindingFilter{VulnClass: marker, Sort: tc.field, TimeDirection: tc.direction}, 1, 1)
if err != nil || total != 2 || reports != len(ids)+1 || len(groups) != 1 || groups[0].TaskID == nil || *groups[0].TaskID != tc.want {
t.Fatalf("%s/%s groups=%+v total=%d reports=%d err=%v", tc.field, tc.direction, groups, total, reports, err)
}
}
}
func TestFindingTimeDirectionAllowlist(t *testing.T) {
for _, raw := range []string{"", "desc", "invalid", "ASC", "asc; SELECT 1"} {
if got := (FindingFilter{TimeDirection: raw}).timeOrder(); got != "DESC" {
t.Fatalf("timeOrder(%q)=%q, want DESC", raw, got)
}
}
if got := (FindingFilter{TimeDirection: "asc"}).timeOrder(); got != "ASC" {
t.Fatalf("timeOrder(asc)=%q", got)
}
}
// A newer low-severity record and an older high-severity record make time
// ordering observably different from severity ordering in both directions.
func timeSortFixture(t *testing.T) (*DB, *Task, []int64) {
t.Helper()
d, task, ids := caseTestDB(t)
for i, day := range []int{25, 26, 27, 24} {
if _, err := d.Exec(`UPDATE findings SET created_at=$2::timestamptz WHERE id=$1`, ids[i], fmt.Sprintf("2026-10-%dT12:00:00Z", day)); err != nil {
t.Fatal(err)
}
}
if _, err := d.Exec(`UPDATE findings SET severity='critical' WHERE id=$1`, ids[0]); err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`UPDATE findings SET severity='high' WHERE id=$1`, ids[1]); err != nil {
t.Fatal(err)
}
return d, task, ids
}
func TestFindingTimeSortPages(t *testing.T) {
d, task, ids := timeSortFixture(t)
for _, tc := range []struct {
name, field, direction string
want []int64
}{
{"default newest first", "", "", []int64{ids[2], ids[1], ids[0], ids[3]}},
{"time descending", "time", "desc", []int64{ids[2], ids[1], ids[0], ids[3]}},
{"time ascending", "time", "asc", []int64{ids[3], ids[0], ids[1], ids[2]}},
{"severity descending time", "severity", "desc", []int64{ids[0], ids[1], ids[3], ids[2]}},
{"severity ascending time", "severity", "asc", []int64{ids[0], ids[3], ids[1], ids[2]}},
} {
t.Run(tc.name, func(t *testing.T) {
filter := FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: tc.field, TimeDirection: tc.direction}
var got []int64
for page := 1; page <= 2; page++ {
rows, total, err := d.ListFindingsPage(filter, page, 2)
if err != nil || total != 4 || len(rows) != 2 {
t.Fatalf("page %d: rows=%d total=%d err=%v", page, len(rows), total, err)
}
for _, row := range rows {
got = append(got, row.ID)
}
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("paged order=%v, want %v", got, tc.want)
}
})
}
if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-27T12:00:00Z' WHERE task_id=$1`, task.ID); err != nil {
t.Fatal(err)
}
for _, direction := range []string{"asc", "desc"} {
rows, _, err := d.ListFindingsPage(FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: "time", TimeDirection: direction}, 1, 4)
if err != nil {
t.Fatal(err)
}
for i, row := range rows {
if row.ID != ids[len(ids)-1-i] {
t.Fatalf("unstable timestamp tie: %+v", rows)
}
}
}
}
func TestFindingCaseTimeSortPages(t *testing.T) {
d, task, ids := timeSortFixture(t)
caseID, err := d.MergeFindingCase(context.Background(), task.ID, []int64{ids[0], ids[2]}, "same defect", "shared handler", "human")
if err != nil {
t.Fatal(err)
}
latest, err := d.AddFinding(task.ID, 0, "other", "latest low", "low", "summary", "evidence", "worker", nil)
if err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-28T12:00:00Z' WHERE id=$1`, latest); err != nil {
t.Fatal(err)
}
f := func(id int64) string { return fmt.Sprintf("f:%d", id) }
c := fmt.Sprintf("c:%d", caseID)
for _, tc := range []struct {
name, field, direction string
want []string
}{
{"default newest first", "", "", []string{f(latest), c, f(ids[1]), f(ids[3])}},
{"time descending", "time", "desc", []string{f(latest), c, f(ids[1]), f(ids[3])}},
{"time ascending", "time", "asc", []string{f(ids[3]), f(ids[1]), c, f(latest)}},
{"severity descending time", "severity", "desc", []string{c, f(ids[1]), f(ids[3]), f(latest)}},
{"severity ascending time", "severity", "asc", []string{c, f(ids[3]), f(ids[1]), f(latest)}},
} {
t.Run(tc.name, func(t *testing.T) {
filter := FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: tc.field, TimeDirection: tc.direction}
var got []string
for page := 1; page <= 2; page++ {
rows, total, err := d.ListFindingCases(filter, page, 2)
if err != nil || total != 4 || len(rows) != 2 {
t.Fatalf("page %d: rows=%d total=%d err=%v", page, len(rows), total, err)
}
for _, row := range rows {
if row.Case != nil {
got = append(got, fmt.Sprintf("c:%d", row.Case.ID))
} else {
got = append(got, f(row.FindingID))
}
}
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("paged order=%v, want %v", got, tc.want)
}
})
}
// Filtering a folder uses its matching members' timestamps, not merge time.
rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low", Sort: "time", TimeDirection: "asc"}, 1, 10)
if err != nil || total != 2 || len(rows) != 2 || rows[0].Case == nil || rows[0].Case.ID != caseID || rows[1].FindingID != latest {
t.Fatalf("filtered case order=%+v total=%d err=%v", rows, total, err)
}
}
+22 -13
View File
@@ -150,15 +150,16 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
}
// FindingFilter narrows a paginated findings query. Empty-string fields mean "no
// filter on that column". Sort is "severity" (severity desc, then newest) or
// anything else (newest first).
// filter on that column". Sort is "severity" (severity desc, then time) or
// anything else (time only). TimeDirection defaults to newest first.
type FindingFilter struct {
Severity string // high | medium | low
Status string // pending | false_positive | ignored | resolved
VulnClass string
TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选)
Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词
Sort string // "severity" | "time"
Severity string // high | medium | low
Status string // pending | false_positive | ignored | resolved
VulnClass string
TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选)
Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词
Sort string // "severity" | "time"
TimeDirection string // "asc" | "desc"; applies to time, not the severity ranking
// AssetScope 是资产树的节点 key(a:<id> / c:<id> / r:<domain> / __none__),
// 选中一个节点等于选中它的整棵子树。空 = 不按资产筛选。
AssetScope string
@@ -169,6 +170,14 @@ type FindingFilter struct {
assetMiss bool // 选中的节点在当前筛选下不存在 → 结果恒空
}
// timeOrder only returns SQL keywords from a fixed allowlist.
func (f FindingFilter) timeOrder() string {
if f.TimeDirection == "asc" {
return "ASC"
}
return "DESC"
}
// FindingUnassignedTask is the task filter sentinel for findings whose task is
// absent. That includes rows created without a task and rows retained after their
// originating task was deleted (the findings FK is ON DELETE SET NULL).
@@ -259,10 +268,10 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
return []*DBFinding{}, total, nil
}
order := "f.created_at DESC, f.id DESC"
order := "f.created_at " + f.timeOrder() + ", f.id DESC"
if f.Sort == "severity" {
// critical > high > medium > low > 其它, then newest first.
order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC, f.id DESC`
// critical > high > medium > low > 其它, then the requested time direction.
order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, ` + order
}
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
q := fmt.Sprintf(`
@@ -324,9 +333,9 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
return []FindingGroup{}, groupTotal, findingTotal, nil
}
order := "MAX(f.created_at) DESC, t.id DESC NULLS LAST"
order := "MAX(f.created_at) " + f.timeOrder() + ", t.id DESC NULLS LAST"
if f.Sort == "severity" {
order = `MAX(CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) DESC, MAX(f.created_at) DESC, t.id DESC NULLS LAST`
order = `MAX(CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) DESC, ` + order
}
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
query := fmt.Sprintf(`SELECT t.id, COALESCE(t.name,''), COALESCE(t.description,''), COALESCE(
+5 -4
View File
@@ -33,10 +33,11 @@ func findingFilterFromQuery(q url.Values) db.FindingFilter {
Status: normFilter(q.Get("status")),
VulnClass: normFilter(q.Get("vulnclass")),
// task_id(独立于会切到「按任务节点」分支的 task 参数):全局表按任务筛选。
TaskID: normFilter(q.Get("task_id")),
Query: q.Get("q"),
Sort: q.Get("sort"),
AssetScope: strings.TrimSpace(q.Get("asset_scope")),
TaskID: normFilter(q.Get("task_id")),
Query: q.Get("q"),
Sort: q.Get("sort"),
TimeDirection: q.Get("time_direction"),
AssetScope: strings.TrimSpace(q.Get("asset_scope")),
}
}
+16
View File
@@ -0,0 +1,16 @@
package server
import (
"net/url"
"testing"
)
func TestFindingFilterTimeDirection(t *testing.T) {
for _, direction := range []string{"", "asc", "desc"} {
q := url.Values{"task_id": {"9"}, "sort": {"time"}, "time_direction": {direction}, "severity": {"high"}, "q": {"keyword"}}
f := findingFilterFromQuery(q)
if f.Sort != "time" || f.TimeDirection != direction || f.TaskID != "9" || f.Severity != "high" || f.Query != "keyword" {
t.Fatalf("filter lost sorting or search state: %+v", f)
}
}
}
@@ -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} />
+1
View File
@@ -241,6 +241,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;
}
+57 -17
View File
@@ -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"), []);
});
+6 -3
View File
@@ -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 -5
View File
@@ -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));
+1
View File
@@ -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;
}