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:
Autumn-27 committed 2026-08-31 10:58:32 -04:00
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>
);
}
+176 -33
View File
@@ -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
View File
@@ -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()}`, {
+33
View File
@@ -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"