feat(findings): 发现页新增「按资产」视图
左侧资产树 + 右侧该子树下的发现,与另外两个视图共用同一张表和同一批筛选。 后端 - db/finding_assets.go:按当前筛选构建资产树。只收有发现的资产,祖先链逐层 补齐(endpoint→service→subdomain/ip→root_domain),父子优先级与 BuildCoverageGraph 同源;计数沿祖先链去重累加,一条发现挂多个资产不会让 父节点重复计数 - FindingFilter 加 AssetScope:选中节点=选中整棵子树,解析成资产 id 集合后 下推 SQL,列表 / 分组 / 导出三个入口共用,导出「当前筛选」自动带上 - 「未关联资产」同时收 asset_ids 为空与指向已删资产的发现,与树上桶的口径 一致(只判空数组会让桶上的数字大于点开后的条数) - findingFilterFromQuery 统一四处的筛选解析;新增 GIN 索引 idx_findings_asset_ids,按资产反查发现不再全表扫 前端 - 树默认展开一层,支持过滤(匹配项自动展开)、面包屑下钻、手动刷新 - 每层只显示相对上一层的增量:子域名去根域名后缀、服务显示 `https :443`、 接口只留路径。完整值在 title 与面包屑,搜索仍按完整值匹配 - 不轮询:进入视图 / 改筛选 / 页内改动发现 / 点刷新时才查 - 节点超上限时整层丢弃接口/服务并提示,计数已在上层不会丢
This commit is contained in:
1 parent
796dbf5600
commit
23cfb9ed4e
11 files changed
+1582
-82
No files matched your search
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BuildingIcon,
|
||||
ChevronRightIcon,
|
||||
CircleDashedIcon,
|
||||
GlobeIcon,
|
||||
LayoutTemplateIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
NetworkIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { FindingAssetKind, FindingAssetNode } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 图标沿用资产页的类型映射,同一种资产在两处长得一样。
|
||||
const KIND_ICON: Record<FindingAssetKind, LucideIcon> = {
|
||||
company: BuildingIcon,
|
||||
root_domain: GlobeIcon,
|
||||
subdomain: GlobeIcon,
|
||||
ip: NetworkIcon,
|
||||
app: SmartphoneIcon,
|
||||
service: LayoutTemplateIcon,
|
||||
endpoint: LinkIcon,
|
||||
none: CircleDashedIcon,
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<FindingAssetKind, string> = {
|
||||
company: "企业",
|
||||
root_domain: "根域名",
|
||||
subdomain: "子域名",
|
||||
ip: "IP",
|
||||
app: "应用",
|
||||
service: "服务",
|
||||
endpoint: "接口",
|
||||
none: "未关联",
|
||||
};
|
||||
|
||||
// TreeNode 是节点数组组装出来的树。后端已按「同父下发现多的在前」排好序,
|
||||
// 这里只需按数组顺序挂载。
|
||||
interface TreeNode extends FindingAssetNode {
|
||||
children: TreeNode[];
|
||||
depth: number;
|
||||
/** 树上真正渲染的文字;完整 label 仍保留在 label 里(悬停提示与面包屑用)。 */
|
||||
display: string;
|
||||
}
|
||||
|
||||
function stripBrackets(host: string) {
|
||||
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||
}
|
||||
|
||||
function parseAssetURL(raw: string): URL | null {
|
||||
try {
|
||||
return new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// shortLabel 去掉与父节点重复的前缀。service / endpoint 的 label 是完整 URL,而
|
||||
// 宿主域名/IP 上一行已经写过了 —— 深层节点本来就窄,再把 host 重复一遍,真正有
|
||||
// 信息量的端口和路径就全被截断掉了。完整值仍在 title 与面包屑里。
|
||||
function shortLabel(node: FindingAssetNode, parent?: FindingAssetNode): string {
|
||||
if (!parent) return node.label;
|
||||
|
||||
// 子域名挂在根域名下:去掉根域名后缀,只留自己那一段。
|
||||
if (node.kind === "subdomain" && node.label.endsWith(`.${parent.label}`)) {
|
||||
return node.label.slice(0, -(parent.label.length + 1)) || node.label;
|
||||
}
|
||||
if (node.kind !== "service" && node.kind !== "endpoint") return node.label;
|
||||
|
||||
// 接口挂在服务下:直接砍掉服务那段前缀,剩下的就是路径。
|
||||
if (node.label.startsWith(parent.label)) {
|
||||
return node.label.slice(parent.label.length) || node.label;
|
||||
}
|
||||
|
||||
const url = parseAssetURL(node.label);
|
||||
if (!url) return node.label;
|
||||
// 只有父节点确实就是这个 URL 的宿主时才简写,否则会丢掉辨识信息。
|
||||
if (stripBrackets(url.hostname) !== parent.label) return node.label;
|
||||
|
||||
if (node.kind === "endpoint") return `${url.pathname}${url.search}` || "/";
|
||||
const scheme = url.protocol.replace(":", "");
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
return `${scheme} :${port}`;
|
||||
}
|
||||
|
||||
export function buildAssetTree(nodes: FindingAssetNode[]): TreeNode[] {
|
||||
const byKey = new Map<string, TreeNode>();
|
||||
for (const node of nodes) {
|
||||
byKey.set(node.key, { ...node, children: [], depth: 0, display: node.label });
|
||||
}
|
||||
const roots: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const current = byKey.get(node.key);
|
||||
if (!current) continue;
|
||||
const parent = node.parent ? byKey.get(node.parent) : undefined;
|
||||
// 父节点缺失(被截断层级丢掉)时上提为顶层,不让子树整个消失。
|
||||
if (parent) {
|
||||
parent.children.push(current);
|
||||
current.display = shortLabel(node, parent);
|
||||
} else {
|
||||
roots.push(current);
|
||||
}
|
||||
}
|
||||
const setDepth = (node: TreeNode, depth: number) => {
|
||||
node.depth = depth;
|
||||
for (const child of node.children) setDepth(child, depth + 1);
|
||||
};
|
||||
for (const root of roots) setDepth(root, 0);
|
||||
return roots;
|
||||
}
|
||||
|
||||
// assetPathOf 返回从顶层到该节点的路径,用于右侧面包屑。每一级同样只显示相对
|
||||
// 上一级的增量(display),完整值留在 label 里。
|
||||
export function assetPathOf(nodes: FindingAssetNode[], key: string | null): (FindingAssetNode & { display: string })[] {
|
||||
if (!key) return [];
|
||||
const byKey = new Map(nodes.map((n) => [n.key, n]));
|
||||
const path: FindingAssetNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byKey.get(key);
|
||||
while (current && !seen.has(current.key)) {
|
||||
seen.add(current.key);
|
||||
path.unshift(current);
|
||||
current = current.parent ? byKey.get(current.parent) : undefined;
|
||||
}
|
||||
return path.map((node, index) => ({ ...node, display: shortLabel(node, path[index - 1]) }));
|
||||
}
|
||||
|
||||
// filterTree 按关键词过滤:命中的节点保留,并保留其整条祖先链(祖先自身可以不命中)。
|
||||
// 命中节点的子孙一并保留,便于继续下钻。
|
||||
function filterTree(nodes: TreeNode[], keyword: string): TreeNode[] {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
if (!kw) return nodes;
|
||||
const walk = (node: TreeNode): TreeNode | null => {
|
||||
const hit = node.label.toLowerCase().includes(kw);
|
||||
if (hit) return node;
|
||||
const children = node.children.map(walk).filter((c): c is TreeNode => c !== null);
|
||||
if (children.length === 0) return null;
|
||||
return { ...node, children };
|
||||
};
|
||||
return nodes.map(walk).filter((n): n is TreeNode => n !== null);
|
||||
}
|
||||
|
||||
// collectKeys 收集一棵(子)树里的全部 key,用于「展开全部匹配项」。
|
||||
function collectKeys(nodes: TreeNode[], out: Set<string> = new Set()): Set<string> {
|
||||
for (const node of nodes) {
|
||||
out.add(node.key);
|
||||
collectKeys(node.children, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface AssetTreeProps {
|
||||
nodes: FindingAssetNode[];
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
loading?: boolean;
|
||||
truncated?: boolean;
|
||||
droppedKinds?: string[];
|
||||
/** 未选中任何资产时右侧展示的发现总数,用于「全部资产」那一行。 */
|
||||
findingTotal: number;
|
||||
/** 资产视图不轮询,树的计数靠这个按钮或页面内的增删改来刷新。 */
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function AssetTree({
|
||||
nodes,
|
||||
selected,
|
||||
onSelect,
|
||||
loading,
|
||||
truncated,
|
||||
droppedKinds,
|
||||
findingTotal,
|
||||
onRefresh,
|
||||
}: AssetTreeProps) {
|
||||
const [keyword, setKeyword] = React.useState("");
|
||||
const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set());
|
||||
// 记住用户手动折叠过的节点,免得「默认展开顶层」在每次刷新后又把它们撑开。
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set());
|
||||
|
||||
const roots = React.useMemo(() => buildAssetTree(nodes), [nodes]);
|
||||
const visible = React.useMemo(() => filterTree(roots, keyword), [roots, keyword]);
|
||||
|
||||
// 搜索时把匹配到的分支全部展开,否则命中项藏在折叠节点里等于没搜。
|
||||
const searching = keyword.trim() !== "";
|
||||
const searchKeys = React.useMemo(() => (searching ? collectKeys(visible) : null), [searching, visible]);
|
||||
|
||||
const isExpanded = React.useCallback(
|
||||
(node: TreeNode) => {
|
||||
if (searchKeys) return searchKeys.has(node.key);
|
||||
if (expanded.has(node.key)) return true;
|
||||
// 顶层默认展开一层:再深的层级要用户自己点开,免得一次铺开上千行。
|
||||
return node.depth === 0 && !collapsed.has(node.key);
|
||||
},
|
||||
[collapsed, expanded, searchKeys],
|
||||
);
|
||||
|
||||
const toggle = React.useCallback(
|
||||
(node: TreeNode) => {
|
||||
const open = isExpanded(node);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.delete(node.key);
|
||||
else next.add(node.key);
|
||||
return next;
|
||||
});
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.add(node.key);
|
||||
else next.delete(node.key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isExpanded],
|
||||
);
|
||||
|
||||
let emptyHint = "当前筛选下没有关联到资产的发现。";
|
||||
if (loading) emptyHint = "加载中…";
|
||||
else if (searching) emptyHint = "没有匹配的资产。";
|
||||
|
||||
const rows: React.ReactNode[] = [];
|
||||
const pushRows = (list: TreeNode[]) => {
|
||||
for (const node of list) {
|
||||
const open = isExpanded(node);
|
||||
rows.push(
|
||||
<AssetTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
open={open}
|
||||
selected={selected === node.key}
|
||||
onToggle={() => toggle(node)}
|
||||
onSelect={() => onSelect(selected === node.key ? null : node.key)}
|
||||
/>,
|
||||
);
|
||||
if (open && node.children.length > 0) pushRows(node.children);
|
||||
}
|
||||
};
|
||||
pushRows(visible);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="过滤资产"
|
||||
aria-label="过滤资产"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 shrink-0 text-muted-foreground"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label="刷新资产树"
|
||||
title="刷新资产树"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm",
|
||||
selected === null ? "bg-accent font-medium" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span>全部资产</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{findingTotal}</span>
|
||||
</button>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col pr-2">
|
||||
{rows}
|
||||
{rows.length === 0 && <p className="px-2 py-8 text-center text-xs text-muted-foreground">{emptyHint}</p>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{truncated && (
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
资产过多,已隐藏{(droppedKinds ?? []).map((k) => KIND_LABEL[k as FindingAssetKind] ?? k).join(" / ")}
|
||||
层级(计数仍已计入上层)。用筛选或过滤框收窄可看到完整层级。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetTreeRow({
|
||||
node,
|
||||
open,
|
||||
selected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
open: boolean;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = KIND_ICON[node.kind] ?? GlobeIcon;
|
||||
const hasChildren = node.children.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-1 text-sm",
|
||||
selected ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
style={{ paddingLeft: `${node.depth * 10}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
aria-label={open ? "折叠" : "展开"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ChevronRightIcon className={cn("size-3.5 transition-transform", open && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="size-5 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left"
|
||||
title={`${KIND_LABEL[node.kind] ?? node.kind} · ${node.label}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className={cn("min-w-0 truncate", selected && "font-medium")}>{node.display}</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums">
|
||||
{node.critical > 0 && (
|
||||
<span className="text-rose-600" title={`严重 ${node.critical}`}>
|
||||
{node.critical}
|
||||
</span>
|
||||
)}
|
||||
{node.high > 0 && (
|
||||
<span className="text-red-500" title={`高危 ${node.high}`}>
|
||||
{node.high}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground" title={`共 ${node.total} 条发现`}>
|
||||
{node.total}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,9 +40,10 @@ 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 type { Finding, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types";
|
||||
import type { Finding, FindingAssetNode, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AssetTree, assetPathOf } from "./_components/asset-tree";
|
||||
import {
|
||||
FINDING_STATUSES,
|
||||
type FindingEdit,
|
||||
@@ -57,10 +58,31 @@ import {
|
||||
|
||||
const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
|
||||
|
||||
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠。
|
||||
type FindingView = "flat" | "grouped";
|
||||
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
|
||||
// asset = 左侧资产树 + 右侧该子树下的发现。
|
||||
type FindingView = "flat" | "grouped" | "asset";
|
||||
|
||||
const FINDING_VIEWS: FindingView[] = ["flat", "grouped"];
|
||||
const FINDING_VIEWS: FindingView[] = ["flat", "grouped", "asset"];
|
||||
|
||||
// 资产树的一次性快照。与另外两个视图不同,资产视图不轮询:进入视图、改筛选、
|
||||
// 或本页改动了发现之后才重新查询。
|
||||
interface AssetTreeState {
|
||||
nodes: FindingAssetNode[];
|
||||
findingTotal: number;
|
||||
truncated: boolean;
|
||||
droppedKinds: string[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ASSET_TREE: AssetTreeState = {
|
||||
nodes: [],
|
||||
findingTotal: 0,
|
||||
truncated: false,
|
||||
droppedKinds: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
// 分组视图里每个已展开任务组自带一份分页状态,彼此独立。
|
||||
interface GroupFindingsState {
|
||||
@@ -110,6 +132,8 @@ export default function FindingsPage() {
|
||||
const [flat, setFlat] = React.useState<FlatFindingsState>(EMPTY_FLAT_STATE);
|
||||
const [flatPage, setFlatPage] = React.useState(1);
|
||||
const [flatPageSize, setFlatPageSize] = React.useState(20);
|
||||
const [assetTree, setAssetTree] = React.useState<AssetTreeState>(EMPTY_ASSET_TREE);
|
||||
const [assetScope, setAssetScope] = React.useState<string | null>(null);
|
||||
const [groups, setGroups] = React.useState<FindingGroup[]>([]);
|
||||
const [groupTotal, setGroupTotal] = React.useState(0);
|
||||
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(() => new Set());
|
||||
@@ -234,6 +258,7 @@ export default function FindingsPage() {
|
||||
}
|
||||
|
||||
const flatRequest = React.useRef(0);
|
||||
const assetTreeRequest = React.useRef(0);
|
||||
const groupRequests = React.useRef<Record<string, number>>({});
|
||||
const groupsRequest = React.useRef(0);
|
||||
const flatStateRef = React.useRef(flat);
|
||||
@@ -245,6 +270,9 @@ export default function FindingsPage() {
|
||||
groupFindingsRef.current = groupFindings;
|
||||
visibleGroupKeysRef.current = new Set(groups.map(findingGroupKey));
|
||||
|
||||
// 资产视图右侧列表 = 平铺列表 + 选中子树的筛选,所以两个视图共用一份列表状态。
|
||||
const activeAssetScope = view === "asset" ? assetScope : null;
|
||||
|
||||
// loadFlat 拉取平铺视图的当前页;task 筛选交给后端,与分组视图共用同一批筛选条件。
|
||||
const loadFlat = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
@@ -261,6 +289,7 @@ export default function FindingsPage() {
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
assetScope: activeAssetScope ?? undefined,
|
||||
});
|
||||
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setFlat({ items: result.items, total: result.total, loaded: true, loading: false });
|
||||
@@ -269,7 +298,32 @@ export default function FindingsPage() {
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
setFlat((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
|
||||
}, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
|
||||
|
||||
// loadAssetTree 取整棵资产树。树不随选中节点变化(否则选一下就塌成一条链),
|
||||
// 所以这里不带 assetScope。
|
||||
const loadAssetTree = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
if (activeFilterFingerprint.current !== requestFilter) return;
|
||||
const request = ++assetTreeRequest.current;
|
||||
setAssetTree((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort });
|
||||
if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setAssetTree({
|
||||
nodes: result.nodes ?? [],
|
||||
findingTotal: result.finding_total ?? 0,
|
||||
truncated: Boolean(result.truncated),
|
||||
droppedKinds: result.dropped_kinds ?? [],
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch (e) {
|
||||
if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setAssetTree((current) => ({ ...current, loading: false }));
|
||||
toast.error(`资产树加载失败:${(e as Error).message}`);
|
||||
}
|
||||
}, [filterFingerprint, severity, status, vulnclass, task, query, sort]);
|
||||
|
||||
const refreshGroups = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
@@ -381,6 +435,12 @@ export default function FindingsPage() {
|
||||
// 行内改动后刷新当前视图:平铺视图重拉当前页,分组视图刷组头 + 该发现所在的组。
|
||||
const refreshAfterMutation = React.useCallback(
|
||||
(finding: Finding, removed = false) => {
|
||||
if (view === "asset") {
|
||||
// 资产视图不轮询,所以改完要顺带把树的计数也重新算一次。
|
||||
void loadFlat();
|
||||
void loadAssetTree();
|
||||
return;
|
||||
}
|
||||
if (view === "flat") {
|
||||
// 删空最后一页时,页码由越界修正 effect 回退并连带重新加载。
|
||||
void loadFlat();
|
||||
@@ -395,7 +455,7 @@ export default function FindingsPage() {
|
||||
void loadGroup(key, Math.min(state.page, lastPage), state.pageSize);
|
||||
}
|
||||
},
|
||||
[loadFlat, loadGroup, refreshGroups, view],
|
||||
[loadAssetTree, loadFlat, loadGroup, refreshGroups, view],
|
||||
);
|
||||
|
||||
// Reset every view's pagination and expansion when a shared finding filter changes.
|
||||
@@ -407,14 +467,31 @@ export default function FindingsPage() {
|
||||
setGroupFindings({});
|
||||
setFlatPage(1);
|
||||
setFlat(EMPTY_FLAT_STATE);
|
||||
// 筛选变了树也会变,原先选中的节点可能已经不在树里,退回「全部资产」。
|
||||
setAssetScope(null);
|
||||
setAssetTree(EMPTY_ASSET_TREE);
|
||||
}, [filterFingerprint]);
|
||||
|
||||
// 换资产节点等于换了一份结果集,回到第一页。
|
||||
React.useEffect(() => {
|
||||
void assetScope;
|
||||
setFlatPage(1);
|
||||
}, [assetScope]);
|
||||
|
||||
// 资产树只在进入视图 / 筛选变化时查一次(以及本页改动发现后由
|
||||
// refreshAfterMutation 主动重拉),不做轮询。
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated || view !== "asset") return;
|
||||
void loadAssetTree();
|
||||
}, [loadAssetTree, preferencesHydrated, view]);
|
||||
|
||||
// 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。
|
||||
// 资产视图只查一次(见下面的 return),它的左树是导航结构,没必要每 5 秒重算。
|
||||
// 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated) return;
|
||||
const refresh = () => {
|
||||
if (view === "flat") {
|
||||
if (view === "flat" || view === "asset") {
|
||||
if (!flatStateRef.current.loading) void loadFlat();
|
||||
return;
|
||||
}
|
||||
@@ -426,6 +503,7 @@ export default function FindingsPage() {
|
||||
}
|
||||
};
|
||||
refresh();
|
||||
if (view === "asset") return;
|
||||
const timer = setInterval(refresh, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]);
|
||||
@@ -640,7 +718,12 @@ export default function FindingsPage() {
|
||||
];
|
||||
|
||||
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
|
||||
const filteredTotal = view === "flat" ? flat.total : total;
|
||||
// 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。
|
||||
const filteredTotal = view === "grouped" ? total : flat.total;
|
||||
const assetPath = React.useMemo(
|
||||
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
|
||||
[assetScope, assetTree.nodes, view],
|
||||
);
|
||||
|
||||
const rowProps = {
|
||||
selectedIds,
|
||||
@@ -658,6 +741,34 @@ export default function FindingsPage() {
|
||||
onDelete: deleteFinding,
|
||||
};
|
||||
|
||||
// 平铺视图与资产视图右侧是同一张表 + 同一份分页,只是筛选条件不同。
|
||||
const flatListCard = (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="px-0">
|
||||
{flat.loading && !flat.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={flat.items} selectAllLabel="选择当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={flatPage}
|
||||
pageSize={flatPageSize}
|
||||
total={flat.total}
|
||||
onPageChange={setFlatPage}
|
||||
onPageSizeChange={(nextSize) => {
|
||||
setFlatPageSize(nextSize);
|
||||
setFlatPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -669,6 +780,7 @@ export default function FindingsPage() {
|
||||
<TabsList>
|
||||
<TabsTrigger value="flat">全部发现</TabsTrigger>
|
||||
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
|
||||
<TabsTrigger value="asset">按资产</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -802,32 +914,63 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "flat" ? (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="px-0">
|
||||
{flat.loading && !flat.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={flat.items} selectAllLabel="选择当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={flatPage}
|
||||
pageSize={flatPageSize}
|
||||
total={flat.total}
|
||||
onPageChange={setFlatPage}
|
||||
onPageSizeChange={(nextSize) => {
|
||||
setFlatPageSize(nextSize);
|
||||
setFlatPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
{view === "flat" && flatListCard}
|
||||
|
||||
{view === "asset" && (
|
||||
<div className="grid min-h-0 items-start gap-4 lg:grid-cols-[20rem_minmax(0,1fr)] xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<Card className="gap-0 py-3 lg:sticky lg:top-4">
|
||||
<CardContent className="flex max-h-[24rem] flex-col px-3 lg:max-h-[calc(100vh-14rem)]">
|
||||
{assetTree.loading && !assetTree.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<AssetTree
|
||||
nodes={assetTree.nodes}
|
||||
selected={assetScope}
|
||||
onSelect={setAssetScope}
|
||||
loading={assetTree.loading}
|
||||
truncated={assetTree.truncated}
|
||||
droppedKinds={assetTree.droppedKinds}
|
||||
findingTotal={assetTree.findingTotal}
|
||||
onRefresh={() => void loadAssetTree()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("hover:text-foreground", assetScope === null && "font-medium text-foreground")}
|
||||
onClick={() => setAssetScope(null)}
|
||||
>
|
||||
全部资产
|
||||
</button>
|
||||
{assetPath.map((node) => (
|
||||
<React.Fragment key={node.key}>
|
||||
<ChevronRightIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"max-w-[16rem] truncate hover:text-foreground",
|
||||
node.key === assetScope && "font-medium text-foreground",
|
||||
)}
|
||||
title={node.label}
|
||||
onClick={() => setAssetScope(node.key)}
|
||||
>
|
||||
{node.display}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="ml-auto shrink-0 text-xs tabular-nums">共 {flat.total} 条</span>
|
||||
</div>
|
||||
{flatListCard}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "grouped" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((group) => {
|
||||
const key = findingGroupKey(group);
|
||||
|
||||
+26
-21
@@ -29,6 +29,7 @@ import type {
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingAssetTree,
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
@@ -178,6 +179,20 @@ const del = <T>(p: string, body?: unknown) =>
|
||||
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
||||
const tq = (task?: string, sep: "?" | "&" = "?") => (task ? `${sep}task=${encodeURIComponent(task)}` : "");
|
||||
|
||||
// findingFilterParams 把发现页的筛选条件序列化成 query string。列表 / 分组 /
|
||||
// 资产树 / 导出共用同一份,新增筛选项只改这里(后端也只解析这一份)。
|
||||
function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
||||
if (q.status && q.status !== "all") p.set("status", q.status);
|
||||
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
|
||||
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.assetScope) p.set("asset_scope", q.assetScope);
|
||||
return p;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
|
||||
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
||||
@@ -456,25 +471,21 @@ export const api = {
|
||||
frontier: (task?: string) => get<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
|
||||
findings: (task?: string) => get<Finding[]>(`/exploration/findings${tq(task)}`).then(arr),
|
||||
findingsPage: (q: FindingQuery) => {
|
||||
const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) });
|
||||
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
||||
if (q.status && q.status !== "all") p.set("status", q.status);
|
||||
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
|
||||
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);
|
||||
const p = findingFilterParams(q);
|
||||
p.set("page", String(q.page));
|
||||
p.set("limit", String(q.pageSize));
|
||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||
},
|
||||
findingGroups: (q: FindingQuery) => {
|
||||
const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) });
|
||||
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
||||
if (q.status && q.status !== "all") p.set("status", q.status);
|
||||
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
|
||||
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);
|
||||
const p = findingFilterParams(q);
|
||||
p.set("page", String(q.page));
|
||||
p.set("limit", String(q.pageSize));
|
||||
return get<FindingGroupsPage>(`/exploration/findings/groups?${p.toString()}`);
|
||||
},
|
||||
// findingAssetTree 取「按资产」视图的左侧树:只含有发现的资产及其祖先,
|
||||
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
|
||||
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
|
||||
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||
@@ -488,13 +499,7 @@ export const api = {
|
||||
if (opts.scope === "selected") {
|
||||
p.set("ids", (opts.ids ?? []).join(","));
|
||||
} else if (opts.scope === "filtered" && opts.filters) {
|
||||
const f = opts.filters;
|
||||
if (f.severity && f.severity !== "all") p.set("severity", f.severity);
|
||||
if (f.status && f.status !== "all") p.set("status", f.status);
|
||||
if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass);
|
||||
if (f.task && f.task !== "all") p.set("task_id", f.task);
|
||||
if (f.query?.trim()) p.set("q", f.query.trim());
|
||||
if (f.sort) p.set("sort", f.sort);
|
||||
for (const [k, v] of findingFilterParams(opts.filters)) p.set(k, v);
|
||||
}
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, {
|
||||
|
||||
@@ -548,8 +548,41 @@ export interface FindingQuery {
|
||||
task?: string; // 任务 id;"all"/空 = 不按任务筛选
|
||||
query?: string;
|
||||
sort?: "severity" | "time";
|
||||
// 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。
|
||||
assetScope?: string;
|
||||
}
|
||||
|
||||
// ---- Findings by asset (资产视图) ----
|
||||
export type FindingAssetKind = "company" | "root_domain" | "subdomain" | "ip" | "service" | "app" | "endpoint" | "none";
|
||||
|
||||
// FindingAssetNode 是资产树的一个节点。key 形如 a:<id>(资产)、c:<id>(企业)、
|
||||
// r:<domain>(库里没有资产行的根域名)、__none__(未关联资产)。
|
||||
export interface FindingAssetNode {
|
||||
key: string;
|
||||
parent?: string;
|
||||
kind: FindingAssetKind;
|
||||
label: string;
|
||||
asset_id?: number;
|
||||
company_id?: number;
|
||||
self: number; // 直接挂在该资产上的发现数
|
||||
total: number; // 含子孙、按发现去重
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
last_found_at: string;
|
||||
}
|
||||
|
||||
export interface FindingAssetTree {
|
||||
nodes: FindingAssetNode[];
|
||||
finding_total: number;
|
||||
truncated: boolean;
|
||||
dropped_kinds?: string[];
|
||||
}
|
||||
|
||||
// FINDING_UNASSIGNED_ASSET 与后端 db.FindingUnassignedAsset 对应。
|
||||
export const FINDING_UNASSIGNED_ASSET = "__none__";
|
||||
|
||||
// ---- Activity / sessions ----
|
||||
export type ActivityKind =
|
||||
| "tool_use"
|
||||
|
||||
Reference in new issue
Block a user