mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-04 03:33:50 +08:00
Merge pull request #79 from RuoJi6/codex/finding-date-filter
新增漏洞发现时间范围筛选
This commit is contained in:
6 files changed
+192
-7
No files matched your search
@@ -160,6 +160,8 @@ type FindingFilter struct {
|
||||
Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词
|
||||
Sort string // "severity" | "time"
|
||||
TimeDirection string // "asc" | "desc"; applies to time, not the severity ranking
|
||||
From string // RFC3339 timestamp, inclusive
|
||||
To string // RFC3339 timestamp, inclusive
|
||||
// AssetScope 是资产树的节点 key(a:<id> / c:<id> / r:<domain> / __none__),
|
||||
// 选中一个节点等于选中它的整棵子树。空 = 不按资产筛选。
|
||||
AssetScope string
|
||||
@@ -199,6 +201,18 @@ func (f FindingFilter) where() (string, []any) {
|
||||
add("severity", f.Severity)
|
||||
add("status", f.Status)
|
||||
add("vulnclass", f.VulnClass)
|
||||
if from := strings.TrimSpace(f.From); from != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, from); err == nil {
|
||||
args = append(args, parsed)
|
||||
conds = append(conds, fmt.Sprintf("f.created_at >= $%d", len(args)))
|
||||
}
|
||||
}
|
||||
if to := strings.TrimSpace(f.To); to != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, to); err == nil {
|
||||
args = append(args, parsed)
|
||||
conds = append(conds, fmt.Sprintf("f.created_at <= $%d", len(args)))
|
||||
}
|
||||
}
|
||||
// task_id 是 bigint 列,按整数比较(不能走上面的文本 add);空/非法值忽略。
|
||||
if f.TaskID == FindingUnassignedTask {
|
||||
conds = append(conds, "(f.task_id IS NULL OR t.id IS NULL)")
|
||||
|
||||
@@ -37,6 +37,8 @@ func findingFilterFromQuery(q url.Values) db.FindingFilter {
|
||||
Query: q.Get("q"),
|
||||
Sort: q.Get("sort"),
|
||||
TimeDirection: q.Get("time_direction"),
|
||||
From: strings.TrimSpace(q.Get("from")),
|
||||
To: strings.TrimSpace(q.Get("to")),
|
||||
AssetScope: strings.TrimSpace(q.Get("asset_scope")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingCaseList } from "@/components/finding-case-list";
|
||||
import { FindingDateFilter, type FindingDateRange } from "@/components/finding-date-filter";
|
||||
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
@@ -142,6 +143,7 @@ export default function FindingsPage() {
|
||||
const [sort, setSort] = React.useState<"severity" | "time">("severity");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [dateRange, setDateRange] = React.useState<FindingDateRange>({});
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
const [flat, setFlat] = React.useState<FlatFindingsState>(EMPTY_FLAT_STATE);
|
||||
const [flatPage, setFlatPage] = React.useState(1);
|
||||
@@ -167,7 +169,16 @@ export default function FindingsPage() {
|
||||
const retestRefreshVersion = React.useRef(0);
|
||||
const [deepenDescription, setDeepenDescription] = React.useState("");
|
||||
const [deepening, setDeepening] = React.useState(false);
|
||||
const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]);
|
||||
const filterFingerprint = JSON.stringify([
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
sort,
|
||||
query,
|
||||
dateRange.from,
|
||||
dateRange.to,
|
||||
]);
|
||||
const activeFilterFingerprint = React.useRef(filterFingerprint);
|
||||
activeFilterFingerprint.current = filterFingerprint;
|
||||
|
||||
@@ -310,6 +321,7 @@ export default function FindingsPage() {
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
...dateRange,
|
||||
assetScope: view === "asset" ? (assetScope ?? undefined) : undefined,
|
||||
},
|
||||
ids: [...selectedIds],
|
||||
@@ -374,7 +386,19 @@ export default function FindingsPage() {
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
setFlat((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
|
||||
}, [
|
||||
activeAssetScope,
|
||||
dateRange,
|
||||
filterFingerprint,
|
||||
flatPage,
|
||||
flatPageSize,
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
]);
|
||||
|
||||
// loadAssetTree 取整棵资产树。树不随选中节点变化(否则选一下就塌成一条链),
|
||||
// 所以这里不带 assetScope。
|
||||
@@ -384,7 +408,7 @@ export default function FindingsPage() {
|
||||
const request = ++assetTreeRequest.current;
|
||||
setAssetTree((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort });
|
||||
const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort, ...dateRange });
|
||||
if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setAssetTree({
|
||||
nodes: result.nodes ?? [],
|
||||
@@ -399,7 +423,7 @@ export default function FindingsPage() {
|
||||
setAssetTree((current) => ({ ...current, loading: false }));
|
||||
toast.error(`资产树加载失败:${(e as Error).message}`);
|
||||
}
|
||||
}, [filterFingerprint, severity, status, vulnclass, task, query, sort]);
|
||||
}, [dateRange, filterFingerprint, severity, status, vulnclass, task, query, sort]);
|
||||
|
||||
const refreshGroups = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
@@ -415,6 +439,7 @@ export default function FindingsPage() {
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
...dateRange,
|
||||
});
|
||||
if (request !== groupsRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setGroups(result.items);
|
||||
@@ -423,7 +448,7 @@ export default function FindingsPage() {
|
||||
} catch {
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
}
|
||||
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
|
||||
}, [dateRange, filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
|
||||
|
||||
const loadGroup = React.useCallback(
|
||||
async (key: string, groupPage: number, groupPageSize: number) => {
|
||||
@@ -454,6 +479,7 @@ export default function FindingsPage() {
|
||||
task: key,
|
||||
query,
|
||||
sort,
|
||||
...dateRange,
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -488,7 +514,7 @@ export default function FindingsPage() {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[filterFingerprint, severity, status, vulnclass, query, sort],
|
||||
[dateRange, filterFingerprint, severity, status, vulnclass, query, sort],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -980,6 +1006,8 @@ export default function FindingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<FindingDateFilter value={dateRange} onChange={setDateRange} />
|
||||
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as "severity" | "time")}>
|
||||
<SelectTrigger size="sm" className="w-36">
|
||||
<SelectValue />
|
||||
@@ -1077,7 +1105,7 @@ export default function FindingsPage() {
|
||||
{view === "cases" && (
|
||||
<FindingCaseList
|
||||
onTotal={setCaseTotal}
|
||||
query={{ severity, status, vulnclass, task, query, sort }}
|
||||
query={{ severity, status, vulnclass, task, query, sort, ...dateRange }}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={toggleSelected}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale/zh-CN";
|
||||
import { CalendarDaysIcon, RotateCcwIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { TimeSelect } from "@/components/time-select";
|
||||
|
||||
export interface FindingDateRange {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
function localIso(date: string, time: string) {
|
||||
if (!date) return undefined;
|
||||
const value = new Date(`${date}T${time || "00:00"}:00`);
|
||||
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
|
||||
}
|
||||
|
||||
function displayDate(iso?: string) {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
function DateField({ value, onChange, label }: { value: string; onChange: (value: string) => void; label: string }) {
|
||||
const selected = value ? parseISO(value) : undefined;
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="h-10 justify-between px-3 font-normal" aria-label={label}>
|
||||
<span className={selected ? "text-foreground" : "text-muted-foreground"}>
|
||||
{selected ? format(selected, "yyyy年M月d日") : "年 / 月 / 日"}
|
||||
</span>
|
||||
<CalendarDaysIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={zhCN}
|
||||
selected={selected}
|
||||
defaultMonth={selected}
|
||||
onSelect={(date) => date && onChange(format(date, "yyyy-MM-dd"))}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingDateFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FindingDateRange;
|
||||
onChange: (value: FindingDateRange) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [fromDate, setFromDate] = React.useState("");
|
||||
const [fromTime, setFromTime] = React.useState("00:00");
|
||||
const [toDate, setToDate] = React.useState("");
|
||||
const [toTime, setToTime] = React.useState("23:59");
|
||||
const active = Boolean(value.from ?? value.to);
|
||||
|
||||
const apply = (next: { fromDate?: string; fromTime?: string; toDate?: string; toTime?: string }) => {
|
||||
const nextFromDate = next.fromDate ?? fromDate;
|
||||
const nextFromTime = next.fromTime ?? fromTime;
|
||||
const nextToDate = next.toDate ?? toDate;
|
||||
const nextToTime = next.toTime ?? toTime;
|
||||
if (next.fromDate !== undefined) setFromDate(next.fromDate);
|
||||
if (next.fromTime !== undefined) setFromTime(next.fromTime);
|
||||
if (next.toDate !== undefined) setToDate(next.toDate);
|
||||
if (next.toTime !== undefined) setToTime(next.toTime);
|
||||
onChange({ from: localIso(nextFromDate, nextFromTime), to: localIso(nextToDate, nextToTime) });
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
setFromTime("00:00");
|
||||
setToTime("23:59");
|
||||
onChange({});
|
||||
};
|
||||
|
||||
const label = active
|
||||
? `${displayDate(value.from) || "开始不限"} – ${displayDate(value.to) || "结束不限"}`
|
||||
: "时间筛选";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={active ? "secondary" : "outline"}
|
||||
className="max-w-64 justify-start gap-2 font-normal"
|
||||
aria-label="按时间筛选"
|
||||
>
|
||||
<CalendarDaysIcon aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-4">
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">按发现时间筛选</p>
|
||||
<p className="text-muted-foreground text-xs">选择年月日和时分,范围包含起止时刻。</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-1.5 text-xs font-medium">
|
||||
<span>开始时间</span>
|
||||
<div className="grid grid-cols-[1fr_8.5rem] gap-2">
|
||||
<DateField value={fromDate} onChange={(value) => apply({ fromDate: value })} label="开始日期" />
|
||||
<TimeSelect value={fromTime} onValueChange={(value) => apply({ fromTime: value })} aria-label="开始时分" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5 text-xs font-medium">
|
||||
<span>结束时间</span>
|
||||
<div className="grid grid-cols-[1fr_8.5rem] gap-2">
|
||||
<DateField value={toDate} onChange={(value) => apply({ toDate: value })} label="结束日期" />
|
||||
<TimeSelect value={toTime} onValueChange={(value) => apply({ toTime: value })} aria-label="结束时分" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<Button variant="ghost" size="sm" className="mt-3 w-full" onClick={clear}>
|
||||
<RotateCcwIcon />
|
||||
清除时间筛选
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -243,6 +243,8 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
|
||||
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.from) p.set("from", q.from);
|
||||
if (q.to) p.set("to", q.to);
|
||||
if (q.assetScope) p.set("asset_scope", q.assetScope);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -608,6 +608,8 @@ export interface FindingQuery {
|
||||
query?: string;
|
||||
sort?: "severity" | "time";
|
||||
timeDirection?: "asc" | "desc"; // Missing = newest first; secondary to severity when sort="severity".
|
||||
from?: string; // ISO timestamp, inclusive
|
||||
to?: string; // ISO timestamp, inclusive
|
||||
// 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。
|
||||
assetScope?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user