feat(ui): add finding-groups server actions and adapter

- Create server actions for finding-groups and resources endpoints
- Add adapter to transform API responses to table row shapes
- Map filter[search] to filter[check_id__icontains] for search support

Ref: PROWLER-881
This commit is contained in:
alejandrobailo
2026-03-18 14:06:53 +01:00
parent c6c5bc655f
commit fec30d9f4e
3 changed files with 278 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import {
FindingGroupRow,
FindingResourceRow,
FINDINGS_ROW_TYPE,
FindingStatus,
ProviderType,
Severity,
} from "@/types";
/**
* API response shape for a finding group (JSON:API).
* Each group represents a unique check_id with aggregated counts.
*
* Fields come from FindingGroupSerializer which aggregates
* FindingGroupDailySummary rows by check_id.
*/
interface FindingGroupApiItem {
type: "finding-groups";
id: string;
attributes: {
check_id: string;
check_title: string | null;
check_description: string | null;
severity: string;
status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase)
impacted_providers: string[];
resources_total: number;
resources_fail: number;
pass_count: number;
fail_count: number;
muted_count: number;
new_count: number;
changed_count: number;
first_seen_at: string | null;
last_seen_at: string | null;
failing_since: string | null;
};
}
/**
* Transforms the API response for finding groups into FindingGroupRow[].
*/
export function adaptFindingGroupsResponse(
apiResponse: any,
): FindingGroupRow[] {
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
return [];
}
return apiResponse.data.map((item: FindingGroupApiItem) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.GROUP,
checkId: item.attributes.check_id,
checkTitle: item.attributes.check_title || item.attributes.check_id,
severity: item.attributes.severity as Severity,
status: item.attributes.status as FindingStatus,
resourcesTotal: item.attributes.resources_total,
resourcesFail: item.attributes.resources_fail,
newCount: item.attributes.new_count,
changedCount: item.attributes.changed_count,
mutedCount: item.attributes.muted_count,
providers: (item.attributes.impacted_providers || []) as ProviderType[],
updatedAt: item.attributes.last_seen_at || "",
}));
}
/**
* API response shape for a finding group resource (drill-down).
* Endpoint: /finding-groups/{check_id}/resources
*
* Each item has nested `resource` and `provider` objects in attributes
* (NOT JSON:API included — it's a custom serializer).
*/
interface FindingGroupResourceApiItem {
type: "finding-group-resources";
id: string;
attributes: {
resource: {
uid: string;
name: string;
service: string;
region: string;
type: string;
};
provider: {
type: string;
uid: string;
alias: string;
};
status: string;
severity: string;
first_seen_at: string | null;
last_seen_at: string | null;
};
}
/**
* Transforms the API response for finding group resources (drill-down)
* into FindingResourceRow[].
*/
export function adaptFindingGroupResourcesResponse(
apiResponse: any,
checkId: string,
): FindingResourceRow[] {
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
return [];
}
return apiResponse.data.map((item: FindingGroupResourceApiItem) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.RESOURCE,
findingId: item.id,
checkId,
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
providerAlias: item.attributes.provider?.alias || "",
providerUid: item.attributes.provider?.uid || "",
resourceName: item.attributes.resource?.name || "-",
resourceUid: item.attributes.resource?.uid || "-",
service: item.attributes.resource?.service || "-",
region: item.attributes.resource?.region || "-",
severity: (item.attributes.severity || "informational") as Severity,
status: item.attributes.status,
isMuted: item.attributes.status === "MUTED",
mutedReason: undefined,
firstSeenAt: item.attributes.first_seen_at,
lastSeenAt: item.attributes.last_seen_at,
}));
}

View File

@@ -0,0 +1,148 @@
"use server";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
/**
* Maps filter[search] to filter[check_id__icontains] for finding-groups.
* The finding-groups endpoint doesn't support filter[search] but supports
* check_id__icontains for substring matching on check IDs and titles.
*/
function mapSearchFilter(
filters: Record<string, string | string[] | undefined>,
): Record<string, string | string[] | undefined> {
const mapped = { ...filters };
const searchValue = mapped["filter[search]"];
if (searchValue) {
mapped["filter[check_id__icontains]"] = searchValue;
delete mapped["filter[search]"];
}
return mapped;
}
export const getFindingGroups = async ({
page = 1,
pageSize = 10,
sort = "",
filters = {},
}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/findings");
const url = new URL(`${apiBaseUrl}/finding-groups`);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
if (sort) url.searchParams.append("sort", sort);
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
console.log("[finding-groups] GET", decodeURIComponent(url.toString()));
try {
const response = await fetch(url.toString(), { headers });
console.log("[finding-groups]", response.status);
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching finding groups:", error);
return undefined;
}
};
export const getLatestFindingGroups = async ({
page = 1,
pageSize = 10,
sort = "",
filters = {},
}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/findings");
const url = new URL(`${apiBaseUrl}/finding-groups/latest`);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
if (sort) url.searchParams.append("sort", sort);
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
console.log("[finding-groups/latest] GET", decodeURIComponent(url.toString()));
try {
const response = await fetch(url.toString(), { headers });
console.log("[finding-groups/latest]", response.status);
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching latest finding groups:", error);
return undefined;
}
};
export const getFindingGroupResources = async ({
checkId,
page = 1,
pageSize = 20,
filters = {},
}: {
checkId: string;
page?: number;
pageSize?: number;
filters?: Record<string, string | string[] | undefined>;
}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/finding-groups/${checkId}/resources`);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
appendSanitizedProviderFilters(url, filters);
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching finding group resources:", error);
return undefined;
}
};
export const getLatestFindingGroupResources = async ({
checkId,
page = 1,
pageSize = 20,
filters = {},
}: {
checkId: string;
page?: number;
pageSize?: number;
filters?: Record<string, string | string[] | undefined>;
}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/finding-groups/latest/${checkId}/resources`,
);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
appendSanitizedProviderFilters(url, filters);
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching latest finding group resources:", error);
return undefined;
}
};

View File

@@ -0,0 +1,2 @@
export * from "./finding-groups";
export * from "./finding-groups.adapter";