Skip to content

Commit 50556df

Browse files
alejandrobailoAdriiiPRodriAlan-TheGentleman
authored
feat(ui): add findings grouped view (#10425)
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com> Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
1 parent 3b87548 commit 50556df

File tree

84 files changed

+5306
-808
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+5306
-808
lines changed

ui/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
44

55
## [1.23.0] (Prowler UNRELEASED)
66

7+
### 🚀 Added
8+
9+
- Findings grouped view with drill-down table showing resources per check, resource detail drawer, infinite scroll pagination, and bulk mute support [(#10425)](https://github.com/prowler-cloud/prowler/pull/10425)
10+
711
### 🔄 Changed
812

913
- Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445)
@@ -31,6 +35,14 @@ All notable changes to the **Prowler UI** are documented in this file.
3135

3236
### 🚀 Added
3337

38+
- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397)
39+
40+
---
41+
42+
## [1.21.0] (Prowler v5.21.0)
43+
44+
### 🚀 Added
45+
3446
- Skill system to Lighthouse AI [(#10322)](https://github.com/prowler-cloud/prowler/pull/10322)
3547
- Skill for creating custom queries on Attack Paths [(#10323)](https://github.com/prowler-cloud/prowler/pull/10323)
3648

ui/actions/attack-paths/scans.adapter.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { formatDuration } from "@/lib/date-utils";
12
import { MetaDataProps } from "@/types";
23
import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths";
34

@@ -61,18 +62,6 @@ export function adaptAttackPathScansResponse(
6162
return { data: enrichedData, metadata };
6263
}
6364

64-
/**
65-
* Format duration in seconds to human-readable format
66-
*
67-
* @param seconds - Duration in seconds
68-
* @returns Formatted duration string (e.g., "2m 30s")
69-
*/
70-
function formatDuration(seconds: number): string {
71-
const minutes = Math.floor(seconds / 60);
72-
const remainingSeconds = seconds % 60;
73-
return `${minutes}m ${remainingSeconds}s`;
74-
}
75-
7665
/**
7766
* Check if a scan is recent (completed within last 24 hours)
7867
*
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
FindingGroupRow,
3+
FindingResourceRow,
4+
FINDINGS_ROW_TYPE,
5+
FindingStatus,
6+
ProviderType,
7+
Severity,
8+
} from "@/types";
9+
10+
/**
11+
* API response shape for a finding group (JSON:API).
12+
* Each group represents a unique check_id with aggregated counts.
13+
*
14+
* Fields come from FindingGroupSerializer which aggregates
15+
* FindingGroupDailySummary rows by check_id.
16+
*/
17+
interface FindingGroupAttributes {
18+
check_id: string;
19+
check_title: string | null;
20+
check_description: string | null;
21+
severity: string;
22+
status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase)
23+
impacted_providers: string[];
24+
resources_total: number;
25+
resources_fail: number;
26+
pass_count: number;
27+
fail_count: number;
28+
muted_count: number;
29+
new_count: number;
30+
changed_count: number;
31+
first_seen_at: string | null;
32+
last_seen_at: string | null;
33+
failing_since: string | null;
34+
}
35+
36+
interface FindingGroupApiItem {
37+
type: "finding-groups";
38+
id: string;
39+
attributes: FindingGroupAttributes;
40+
}
41+
42+
/**
43+
* Transforms the API response for finding groups into FindingGroupRow[].
44+
*/
45+
export function adaptFindingGroupsResponse(
46+
apiResponse: any,
47+
): FindingGroupRow[] {
48+
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
49+
return [];
50+
}
51+
52+
return apiResponse.data.map((item: FindingGroupApiItem) => ({
53+
id: item.id,
54+
rowType: FINDINGS_ROW_TYPE.GROUP,
55+
checkId: item.attributes.check_id,
56+
checkTitle: item.attributes.check_title || item.attributes.check_id,
57+
severity: item.attributes.severity as Severity,
58+
status: item.attributes.status as FindingStatus,
59+
resourcesTotal: item.attributes.resources_total,
60+
resourcesFail: item.attributes.resources_fail,
61+
newCount: item.attributes.new_count,
62+
changedCount: item.attributes.changed_count,
63+
mutedCount: item.attributes.muted_count,
64+
providers: (item.attributes.impacted_providers || []) as ProviderType[],
65+
updatedAt: item.attributes.last_seen_at || "",
66+
}));
67+
}
68+
69+
/**
70+
* API response shape for a finding group resource (drill-down).
71+
* Endpoint: /finding-groups/{check_id}/resources
72+
*
73+
* Each item has nested `resource` and `provider` objects in attributes
74+
* (NOT JSON:API included — it's a custom serializer).
75+
*/
76+
interface ResourceInfo {
77+
uid: string;
78+
name: string;
79+
service: string;
80+
region: string;
81+
type: string;
82+
resource_group: string;
83+
}
84+
85+
interface ProviderInfo {
86+
type: string;
87+
uid: string;
88+
alias: string;
89+
}
90+
91+
interface FindingGroupResourceAttributes {
92+
resource: ResourceInfo;
93+
provider: ProviderInfo;
94+
status: string;
95+
severity: string;
96+
first_seen_at: string | null;
97+
last_seen_at: string | null;
98+
muted_reason?: string | null;
99+
}
100+
101+
interface FindingGroupResourceApiItem {
102+
type: "finding-group-resources";
103+
id: string;
104+
attributes: FindingGroupResourceAttributes;
105+
}
106+
107+
/**
108+
* Transforms the API response for finding group resources (drill-down)
109+
* into FindingResourceRow[].
110+
*/
111+
export function adaptFindingGroupResourcesResponse(
112+
apiResponse: any,
113+
checkId: string,
114+
): FindingResourceRow[] {
115+
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
116+
return [];
117+
}
118+
119+
return apiResponse.data.map((item: FindingGroupResourceApiItem) => ({
120+
id: item.id,
121+
rowType: FINDINGS_ROW_TYPE.RESOURCE,
122+
findingId: item.id,
123+
checkId,
124+
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
125+
providerAlias: item.attributes.provider?.alias || "",
126+
providerUid: item.attributes.provider?.uid || "",
127+
resourceName: item.attributes.resource?.name || "-",
128+
resourceGroup: item.attributes.resource?.resource_group || "-",
129+
resourceUid: item.attributes.resource?.uid || "-",
130+
service: item.attributes.resource?.service || "-",
131+
region: item.attributes.resource?.region || "-",
132+
severity: (item.attributes.severity || "informational") as Severity,
133+
status: item.attributes.status,
134+
isMuted: item.attributes.status === "MUTED",
135+
// TODO: remove fallback once the API returns muted_reason in finding-group-resources
136+
mutedReason: item.attributes.muted_reason || undefined,
137+
firstSeenAt: item.attributes.first_seen_at,
138+
lastSeenAt: item.attributes.last_seen_at,
139+
}));
140+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"use server";
2+
3+
import { redirect } from "next/navigation";
4+
5+
import { apiBaseUrl, getAuthHeaders } from "@/lib";
6+
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
7+
import { handleApiResponse } from "@/lib/server-actions-helper";
8+
9+
/**
10+
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
11+
* The finding-groups endpoint supports check_title__icontains for substring
12+
* matching on the human-readable check title displayed in the table.
13+
*/
14+
function mapSearchFilter(
15+
filters: Record<string, string | string[] | undefined>,
16+
): Record<string, string | string[] | undefined> {
17+
const mapped = { ...filters };
18+
const searchValue = mapped["filter[search]"];
19+
if (searchValue) {
20+
mapped["filter[check_title__icontains]"] = searchValue;
21+
delete mapped["filter[search]"];
22+
}
23+
return mapped;
24+
}
25+
26+
export const getFindingGroups = async ({
27+
page = 1,
28+
pageSize = 10,
29+
sort = "",
30+
filters = {},
31+
}) => {
32+
const headers = await getAuthHeaders({ contentType: false });
33+
34+
if (isNaN(Number(page)) || page < 1) redirect("/findings");
35+
36+
const url = new URL(`${apiBaseUrl}/finding-groups`);
37+
38+
if (page) url.searchParams.append("page[number]", page.toString());
39+
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
40+
if (sort) url.searchParams.append("sort", sort);
41+
42+
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
43+
44+
try {
45+
const response = await fetch(url.toString(), { headers });
46+
return handleApiResponse(response);
47+
} catch (error) {
48+
console.error("Error fetching finding groups:", error);
49+
return undefined;
50+
}
51+
};
52+
53+
export const getLatestFindingGroups = async ({
54+
page = 1,
55+
pageSize = 10,
56+
sort = "",
57+
filters = {},
58+
}) => {
59+
const headers = await getAuthHeaders({ contentType: false });
60+
61+
if (isNaN(Number(page)) || page < 1) redirect("/findings");
62+
63+
const url = new URL(`${apiBaseUrl}/finding-groups/latest`);
64+
65+
if (page) url.searchParams.append("page[number]", page.toString());
66+
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
67+
if (sort) url.searchParams.append("sort", sort);
68+
69+
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
70+
71+
try {
72+
const response = await fetch(url.toString(), { headers });
73+
return handleApiResponse(response);
74+
} catch (error) {
75+
console.error("Error fetching latest finding groups:", error);
76+
return undefined;
77+
}
78+
};
79+
80+
export const getFindingGroupResources = async ({
81+
checkId,
82+
page = 1,
83+
pageSize = 20,
84+
filters = {},
85+
}: {
86+
checkId: string;
87+
page?: number;
88+
pageSize?: number;
89+
filters?: Record<string, string | string[] | undefined>;
90+
}) => {
91+
const headers = await getAuthHeaders({ contentType: false });
92+
93+
const url = new URL(`${apiBaseUrl}/finding-groups/${checkId}/resources`);
94+
95+
if (page) url.searchParams.append("page[number]", page.toString());
96+
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
97+
98+
appendSanitizedProviderFilters(url, filters);
99+
100+
try {
101+
const response = await fetch(url.toString(), {
102+
headers,
103+
});
104+
105+
return handleApiResponse(response);
106+
} catch (error) {
107+
console.error("Error fetching finding group resources:", error);
108+
return undefined;
109+
}
110+
};
111+
112+
export const getLatestFindingGroupResources = async ({
113+
checkId,
114+
page = 1,
115+
pageSize = 20,
116+
filters = {},
117+
}: {
118+
checkId: string;
119+
page?: number;
120+
pageSize?: number;
121+
filters?: Record<string, string | string[] | undefined>;
122+
}) => {
123+
const headers = await getAuthHeaders({ contentType: false });
124+
125+
const url = new URL(
126+
`${apiBaseUrl}/finding-groups/latest/${checkId}/resources`,
127+
);
128+
129+
if (page) url.searchParams.append("page[number]", page.toString());
130+
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
131+
132+
appendSanitizedProviderFilters(url, filters);
133+
134+
try {
135+
const response = await fetch(url.toString(), {
136+
headers,
137+
});
138+
139+
return handleApiResponse(response);
140+
} catch (error) {
141+
console.error("Error fetching latest finding group resources:", error);
142+
return undefined;
143+
}
144+
};

ui/actions/finding-groups/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./finding-groups";
2+
export * from "./finding-groups.adapter";

0 commit comments

Comments
 (0)