fix(ui): findings groups fixes (#10633)

This commit is contained in:
Alejandro Bailo
2026-04-10 10:44:10 +02:00
committed by GitHub
parent e4b2950436
commit 0e8080f09c
36 changed files with 1409 additions and 652 deletions
@@ -78,14 +78,31 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
check_description: null,
severity: "critical",
status: "FAIL",
muted: true,
impacted_providers: ["aws"],
resources_total: 5,
resources_fail: 3,
pass_count: 2,
fail_count: 3,
manual_count: 1,
pass_muted_count: 0,
fail_muted_count: 3,
manual_muted_count: 0,
muted_count: 0,
new_count: 1,
changed_count: 0,
new_fail_count: 0,
new_fail_muted_count: 1,
new_pass_count: 0,
new_pass_muted_count: 0,
new_manual_count: 0,
new_manual_muted_count: 0,
changed_fail_count: 0,
changed_fail_muted_count: 0,
changed_pass_count: 0,
changed_pass_muted_count: 0,
changed_manual_count: 0,
changed_manual_muted_count: 0,
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
failing_since: null,
@@ -101,6 +118,9 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
expect(result).toHaveLength(1);
expect(result[0].checkId).toBe("s3_bucket_public_access");
expect(result[0].checkTitle).toBe("S3 Bucket Public Access");
expect(result[0].muted).toBe(true);
expect(result[0].manualCount).toBe(1);
expect(result[0].newFailMutedCount).toBe(1);
});
});
@@ -149,6 +169,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
id: "resource-row-1",
type: "finding-group-resources",
attributes: {
finding_id: "real-finding-uuid",
resource: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
@@ -163,6 +184,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
alias: "production",
},
status: "FAIL",
muted: true,
delta: "new",
severity: "critical",
first_seen_at: null,
@@ -177,8 +199,10 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
// Then
expect(result).toHaveLength(1);
expect(result[0].findingId).toBe("real-finding-uuid");
expect(result[0].checkId).toBe("s3_check");
expect(result[0].resourceName).toBe("my-bucket");
expect(result[0].delta).toBe("new");
expect(result[0].isMuted).toBe(true);
});
});
@@ -19,15 +19,32 @@ interface FindingGroupAttributes {
check_title: string | null;
check_description: string | null;
severity: string;
status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase)
status: string; // "FAIL" | "PASS" | "MANUAL" (already uppercase)
muted?: boolean;
impacted_providers: string[];
resources_total: number;
resources_fail: number;
pass_count: number;
fail_count: number;
manual_count?: number;
pass_muted_count?: number;
fail_muted_count?: number;
manual_muted_count?: number;
muted_count: number;
new_count: number;
changed_count: number;
new_fail_count?: number;
new_fail_muted_count?: number;
new_pass_count?: number;
new_pass_muted_count?: number;
new_manual_count?: number;
new_manual_muted_count?: number;
changed_fail_count?: number;
changed_fail_muted_count?: number;
changed_pass_count?: number;
changed_pass_muted_count?: number;
changed_manual_count?: number;
changed_manual_muted_count?: number;
first_seen_at: string | null;
last_seen_at: string | null;
failing_since: string | null;
@@ -62,10 +79,33 @@ export function adaptFindingGroupsResponse(
checkTitle: item.attributes.check_title || item.attributes.check_id,
severity: item.attributes.severity as Severity,
status: item.attributes.status as FindingStatus,
muted:
item.attributes.muted ??
(item.attributes.muted_count > 0 &&
(item.attributes.muted_count === item.attributes.resources_fail ||
item.attributes.muted_count === item.attributes.resources_total)),
resourcesTotal: item.attributes.resources_total,
resourcesFail: item.attributes.resources_fail,
passCount: item.attributes.pass_count,
failCount: item.attributes.fail_count,
manualCount: item.attributes.manual_count ?? 0,
passMutedCount: item.attributes.pass_muted_count ?? 0,
failMutedCount: item.attributes.fail_muted_count ?? 0,
manualMutedCount: item.attributes.manual_muted_count ?? 0,
newCount: item.attributes.new_count,
changedCount: item.attributes.changed_count,
newFailCount: item.attributes.new_fail_count ?? 0,
newFailMutedCount: item.attributes.new_fail_muted_count ?? 0,
newPassCount: item.attributes.new_pass_count ?? 0,
newPassMutedCount: item.attributes.new_pass_muted_count ?? 0,
newManualCount: item.attributes.new_manual_count ?? 0,
newManualMutedCount: item.attributes.new_manual_muted_count ?? 0,
changedFailCount: item.attributes.changed_fail_count ?? 0,
changedFailMutedCount: item.attributes.changed_fail_muted_count ?? 0,
changedPassCount: item.attributes.changed_pass_count ?? 0,
changedPassMutedCount: item.attributes.changed_pass_muted_count ?? 0,
changedManualCount: item.attributes.changed_manual_count ?? 0,
changedManualMutedCount: item.attributes.changed_manual_muted_count ?? 0,
mutedCount: item.attributes.muted_count,
providers: (item.attributes.impacted_providers || []) as ProviderType[],
updatedAt: item.attributes.last_seen_at || "",
@@ -95,9 +135,11 @@ interface ProviderInfo {
}
interface FindingGroupResourceAttributes {
finding_id: string;
resource: ResourceInfo;
provider: ProviderInfo;
status: string;
muted?: boolean;
delta?: string | null;
severity: string;
first_seen_at: string | null;
@@ -132,7 +174,7 @@ export function adaptFindingGroupResourcesResponse(
return data.map((item) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.RESOURCE,
findingId: item.id,
findingId: item.attributes.finding_id || item.id,
checkId,
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
providerAlias: item.attributes.provider?.alias || "",
@@ -146,7 +188,7 @@ export function adaptFindingGroupResourcesResponse(
severity: (item.attributes.severity || "informational") as Severity,
status: item.attributes.status,
delta: item.attributes.delta || null,
isMuted: item.attributes.status === "MUTED",
isMuted: item.attributes.muted ?? item.attributes.status === "MUTED",
mutedReason: item.attributes.muted_reason || undefined,
firstSeenAt: item.attributes.first_seen_at,
lastSeenAt: item.attributes.last_seen_at,
@@ -187,7 +187,9 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
// Then — the URL must contain the composite sort
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
});
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
@@ -223,7 +225,9 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
});
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
@@ -265,7 +269,9 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
const url = new URL(calledUrl);
expect(url.searchParams.get("page[number]")).toBe("2");
expect(url.searchParams.get("page[size]")).toBe("50");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
@@ -291,7 +297,9 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
const url = new URL(calledUrl);
expect(url.searchParams.get("page[number]")).toBe("3");
expect(url.searchParams.get("page[size]")).toBe("20");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
@@ -360,7 +368,9 @@ describe("getFindingGroupResources — caller filters are preserved", () => {
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
});
@@ -426,7 +436,9 @@ describe("getLatestFindingGroupResources — caller filters are preserved", () =
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("sort")).toBe(
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe(
"instance-prod",
);
+5 -2
View File
@@ -62,7 +62,10 @@ function normalizeFindingGroupResourceFilters(
}
const DEFAULT_FINDING_GROUPS_SORT =
"-severity,-delta,-fail_count,-last_seen_at";
"-status,-severity,-delta,-fail_count,-last_seen_at";
const DEFAULT_FINDING_GROUP_RESOURCES_SORT =
"-status,-delta,-severity,-last_seen_at";
interface FetchFindingGroupsParams {
page?: number;
@@ -133,7 +136,7 @@ async function fetchFindingGroupResourcesEndpoint(
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
url.searchParams.append("sort", "-severity,-delta,-last_seen_at");
url.searchParams.append("sort", DEFAULT_FINDING_GROUP_RESOURCES_SORT);
appendSanitizedProviderFilters(url, normalizedFilters);
+50 -208
View File
@@ -43,7 +43,6 @@ vi.mock("@/actions/finding-groups", () => ({
}));
import {
resolveFindingIds,
resolveFindingIdsByCheckIds,
resolveFindingIdsByVisibleGroupResources,
} from "./findings-by-resource";
@@ -142,47 +141,6 @@ describe("resolveFindingIdsByCheckIds", () => {
});
});
describe("resolveFindingIds", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should use the dated findings endpoint when date or scan filters are active", async () => {
// Given
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }],
});
// When
const result = await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1", "resource-2"],
hasDateOrScanFilter: true,
filters: {
"filter[scan__in]": "scan-1",
"filter[inserted_at__gte]": "2026-03-01",
},
});
// Then
expect(result).toEqual(["finding-1", "finding-2"]);
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings");
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
"resource-1,resource-2",
);
expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1");
expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe(
"2026-03-01",
);
});
});
describe("resolveFindingIdsByVisibleGroupResources", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -190,22 +148,18 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should resolve finding IDs from the group's visible resource UIDs instead of muting the whole check", async () => {
// Given
it("extracts finding_id directly from group resources without a second resolution round-trip", async () => {
// Given — the group resources endpoint returns finding_id in each resource
getLatestFindingGroupResourcesMock
.mockResolvedValueOnce({
data: [
{
id: "resource-row-1",
attributes: {
resource: { uid: "resource-1" },
},
attributes: { finding_id: "finding-1" },
},
{
id: "resource-row-2",
attributes: {
resource: { uid: "resource-2" },
},
attributes: { finding_id: "finding-2" },
},
],
meta: { pagination: { pages: 2 } },
@@ -214,19 +168,12 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
data: [
{
id: "resource-row-3",
attributes: {
resource: { uid: "resource-3" },
},
attributes: { finding_id: "finding-3" },
},
],
meta: { pagination: { pages: 2 } },
});
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
});
// When
const result = await resolveFindingIdsByVisibleGroupResources({
checkId: "check-1",
@@ -236,8 +183,13 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
resourceSearch: "visible subset",
});
// Then
// Then — finding IDs come directly from the group resources response
expect(result).toEqual(["finding-1", "finding-2", "finding-3"]);
// No second round-trip to /findings/latest
expect(fetchMock).not.toHaveBeenCalled();
// Group resources endpoint was paginated with correct filters
expect(getLatestFindingGroupResourcesMock).toHaveBeenCalledTimes(2);
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(1, {
checkId: "check-1",
@@ -246,6 +198,8 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
filters: {
"filter[provider_type__in]": "aws",
"filter[name__icontains]": "visible subset",
"filter[status]": "FAIL",
"filter[muted]": "false",
},
});
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(2, {
@@ -255,168 +209,56 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
filters: {
"filter[provider_type__in]": "aws",
"filter[name__icontains]": "visible subset",
"filter[status]": "FAIL",
"filter[muted]": "false",
},
});
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings/latest");
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
expect(calledUrl.searchParams.get("filter[check_id__in]")).toBeNull();
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
"resource-1,resource-2,resource-3",
);
});
});
// ---------------------------------------------------------------------------
// Blocker 3: Muting a group mutes ALL historical findings, not just FAIL ones
//
// The fix: resolveFindingIds must include filter[status]=FAIL so only active
// (failing) findings are resolved for mute, not historical/passing ones.
// ---------------------------------------------------------------------------
describe("resolveFindingIds — Blocker 3: only resolve FAIL findings for mute", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should include filter[status]=FAIL in the findings resolution URL for mute", async () => {
it("deduplicates finding IDs across pages", async () => {
// Given — same finding_id appears on both pages
getLatestFindingGroupResourcesMock
.mockResolvedValueOnce({
data: [
{ id: "r-1", attributes: { finding_id: "finding-1" } },
{ id: "r-2", attributes: { finding_id: "finding-2" } },
],
meta: { pagination: { pages: 2 } },
})
.mockResolvedValueOnce({
data: [{ id: "r-3", attributes: { finding_id: "finding-2" } }],
meta: { pagination: { pages: 2 } },
});
// When
const result = await resolveFindingIdsByVisibleGroupResources({
checkId: "check-1",
});
// Then — no duplicates
expect(result).toEqual(["finding-1", "finding-2"]);
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses the dated endpoint when date or scan filters are active", async () => {
// Given
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }],
getFindingGroupResourcesMock.mockResolvedValueOnce({
data: [{ id: "r-1", attributes: { finding_id: "finding-1" } }],
meta: { pagination: { pages: 1 } },
});
// When
await resolveFindingIds({
await resolveFindingIdsByVisibleGroupResources({
checkId: "check-1",
resourceUids: ["resource-1", "resource-2"],
});
// Then — the URL must filter to only FAIL status findings
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
});
it("should include filter[status]=FAIL even when date or scan filters are active", async () => {
// Given
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }],
});
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1"],
hasDateOrScanFilter: true,
filters: {
"filter[inserted_at__gte]": "2026-01-01",
"filter[scan__in]": "scan-1",
},
});
// Then
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings");
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
});
it("should override caller filter[status] with FAIL — no duplicate params", async () => {
// Given — caller passes filter[status]=PASS via filters dict
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }],
});
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1"],
filters: {
"filter[status]": "PASS",
},
});
// Then — hardcoded FAIL must win, exactly 1 value
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
const statusValues = calledUrl.searchParams.getAll("filter[status]");
expect(statusValues).toHaveLength(1);
expect(statusValues[0]).toBe("FAIL");
});
});
// ---------------------------------------------------------------------------
// Fix 4: Unbounded page[size] cap
//
// The bug: createResourceFindingResolutionUrl sets page[size]=resourceUids.length
// with no upper bound guard. The production fix adds Math.min(resourceUids.length, MAX_PAGE_SIZE)
// with MAX_PAGE_SIZE=500 as an explicit defensive cap.
// ---------------------------------------------------------------------------
describe("resolveFindingIds — Fix 4: page[size] explicit cap at MAX_PAGE_SIZE=500", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("should use resourceUids.length as page[size] for a small batch (under 500)", async () => {
// Given — 3 resources, well under the cap
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
});
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids: ["resource-1", "resource-2", "resource-3"],
});
// Then — page[size] should equal the number of resourceUids (3)
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.searchParams.get("page[size]")).toBe("3");
});
it("should cap page[size] at 500 when the chunk has exactly 500 UIDs (boundary value)", async () => {
// Given — exactly 500 unique UIDs (at the cap boundary)
const resourceUids = Array.from({ length: 500 }, (_, i) => `resource-${i}`);
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids,
});
// Then — page[size] must be exactly 500 (not capped lower)
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
});
it("should cap page[size] at 500 even when a chunk would exceed 500 — Math.min guard in URL builder", async () => {
// Given — 501 UIDs. The chunker splits into [500, 1].
// The FIRST chunk has 500 UIDs → page[size] should be 500 (Math.min(500, 500)).
// The SECOND chunk has 1 UID → page[size] should be 1 (Math.min(1, 500)).
// This proves the Math.min cap fires correctly on every chunk.
const resourceUids = Array.from({ length: 501 }, (_, i) => `resource-${i}`);
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue({ data: [] });
// When
await resolveFindingIds({
checkId: "check-1",
resourceUids,
});
// Then — two fetch calls: one for 500 UIDs, one for 1 UID
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
const secondUrl = new URL(fetchMock.mock.calls[1][0] as string);
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
expect(secondUrl.searchParams.get("page[size]")).toBe("1");
// Then — uses getFindingGroupResources (dated), not getLatestFindingGroupResources
expect(getFindingGroupResourcesMock).toHaveBeenCalledTimes(1);
expect(getLatestFindingGroupResourcesMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
+30 -147
View File
@@ -14,22 +14,12 @@ const FINDING_IDS_RESOLUTION_CONCURRENCY = 4;
const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500;
const FINDING_FIELDS = "uid";
/** Explicit upper bound for page[size] in resource-finding resolution requests. */
const MAX_RESOURCE_FINDING_PAGE_SIZE = 500;
interface ResolveFindingIdsByCheckIdsParams {
checkIds: string[];
filters?: Record<string, string>;
hasDateOrScanFilter?: boolean;
}
interface ResolveFindingIdsParams {
checkId: string;
resourceUids: string[];
filters?: Record<string, string>;
hasDateOrScanFilter?: boolean;
}
interface ResolveFindingIdsByVisibleGroupResourcesParams {
checkId: string;
filters?: Record<string, string>;
@@ -42,8 +32,8 @@ interface FindingIdsPageResponse {
totalPages: number;
}
interface FindingGroupResourceUidsPageResponse {
resourceUids: string[];
interface FindingGroupResourceFindingIdsPageResponse {
findingIds: string[];
totalPages: number;
}
@@ -100,78 +90,7 @@ async function fetchFindingIdsPage({
};
}
function chunkValues<T>(values: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += chunkSize) {
chunks.push(values.slice(index, index + chunkSize));
}
return chunks;
}
function createResourceFindingResolutionUrl({
checkId,
resourceUids,
filters = {},
hasDateOrScanFilter = false,
}: ResolveFindingIdsParams): URL {
const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest";
const url = new URL(`${apiBaseUrl}/${endpoint}`);
url.searchParams.append("filter[check_id]", checkId);
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
url.searchParams.append("filter[muted]", "false");
url.searchParams.append(
"page[size]",
Math.min(resourceUids.length, MAX_RESOURCE_FINDING_PAGE_SIZE).toString(),
);
appendSanitizedProviderTypeFilters(url, filters);
// Hardcoded FAIL filter AFTER appendSanitizedProviderTypeFilters — .set()
// guarantees this wins even if the caller passes filter[status] in filters.
url.searchParams.set("filter[status]", "FAIL");
return url;
}
async function fetchFindingIdsForResourceUids({
headers,
...params
}: ResolveFindingIdsParams & {
headers: HeadersInit;
}): Promise<string[]> {
const response = await fetch(
createResourceFindingResolutionUrl(params).toString(),
{
headers,
},
);
const data = await handleApiResponse(response);
if (!data?.data || !Array.isArray(data.data)) {
return [];
}
return data.data
.map((item: { id?: string }) => item.id)
.filter((id: string | undefined): id is string => Boolean(id));
}
function buildFindingGroupResourceFilters({
filters = {},
resourceSearch,
}: Pick<
ResolveFindingIdsByVisibleGroupResourcesParams,
"filters" | "resourceSearch"
>): Record<string, string> {
const nextFilters = { ...filters };
if (resourceSearch) {
nextFilters["filter[name__icontains]"] = resourceSearch;
}
return nextFilters;
}
async function fetchFindingGroupResourceUidsPage({
async function fetchFindingGroupResourceFindingIdsPage({
checkId,
filters = {},
hasDateOrScanFilter = false,
@@ -179,77 +98,44 @@ async function fetchFindingGroupResourceUidsPage({
resourceSearch,
}: ResolveFindingIdsByVisibleGroupResourcesParams & {
page: number;
}): Promise<FindingGroupResourceUidsPageResponse> {
}): Promise<FindingGroupResourceFindingIdsPageResponse> {
const fetchFn = hasDateOrScanFilter
? getFindingGroupResources
: getLatestFindingGroupResources;
const resolvedFilters: Record<string, string> = {
...filters,
"filter[status]": "FAIL",
"filter[muted]": "false",
};
if (resourceSearch) {
resolvedFilters["filter[name__icontains]"] = resourceSearch;
}
const response = await fetchFn({
checkId,
page,
pageSize: FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE,
filters: buildFindingGroupResourceFilters({ filters, resourceSearch }),
filters: resolvedFilters,
});
const data = response?.data;
if (!data || !Array.isArray(data)) {
return { resourceUids: [], totalPages: 1 };
return { findingIds: [], totalPages: 1 };
}
return {
resourceUids: data
findingIds: data
.map(
(item: { attributes?: { resource?: { uid?: string } } }) =>
item.attributes?.resource?.uid,
(item: { attributes?: { finding_id?: string } }) =>
item.attributes?.finding_id,
)
.filter((uid: string | undefined): uid is string => Boolean(uid)),
.filter((id: string | undefined): id is string => Boolean(id)),
totalPages: response?.meta?.pagination?.pages ?? 1,
};
}
/**
* Resolves resource UIDs + check ID into actual finding UUIDs.
* Uses /findings/latest (or /findings when date/scan filters are active)
* with check_id and resource_uid__in filters to batch-resolve actual finding IDs.
*/
export const resolveFindingIds = async ({
checkId,
resourceUids,
filters = {},
hasDateOrScanFilter = false,
}: ResolveFindingIdsParams): Promise<string[]> => {
if (resourceUids.length === 0) {
return [];
}
const headers = await getAuthHeaders({ contentType: false });
const resourceUidChunks = chunkValues(
Array.from(new Set(resourceUids)),
FINDING_IDS_RESOLUTION_PAGE_SIZE,
);
try {
const results = await runWithConcurrencyLimit(
resourceUidChunks,
FINDING_IDS_RESOLUTION_CONCURRENCY,
(resourceUidChunk) =>
fetchFindingIdsForResourceUids({
checkId,
resourceUids: resourceUidChunk,
filters,
hasDateOrScanFilter,
headers,
}),
);
return Array.from(new Set(results.flat()));
} catch (error) {
console.error("Error resolving finding IDs:", error);
return [];
}
};
/**
* Resolves check IDs into actual finding UUIDs.
* Used at the group level where each row represents a check_id.
@@ -305,8 +191,12 @@ export const resolveFindingIdsByCheckIds = async ({
};
/**
* Resolves a finding-group row to the actual findings for the resources
* Resolves a finding-group row to the actual finding UUIDs for the resources
* currently visible in that group.
*
* Extracts finding_id directly from the group resources endpoint response,
* filtering server-side by status=FAIL and muted=false. No second resolution
* round-trip to /findings/latest is needed.
*/
export const resolveFindingIdsByVisibleGroupResources = async ({
checkId,
@@ -315,7 +205,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
resourceSearch,
}: ResolveFindingIdsByVisibleGroupResourcesParams): Promise<string[]> => {
try {
const firstPage = await fetchFindingGroupResourceUidsPage({
const firstPage = await fetchFindingGroupResourceFindingIdsPage({
checkId,
filters,
hasDateOrScanFilter,
@@ -332,7 +222,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
remainingPages,
FINDING_IDS_RESOLUTION_CONCURRENCY,
(page) =>
fetchFindingGroupResourceUidsPage({
fetchFindingGroupResourceFindingIdsPage({
checkId,
filters,
hasDateOrScanFilter,
@@ -341,19 +231,12 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
}),
);
const resourceUids = Array.from(
return Array.from(
new Set([
...firstPage.resourceUids,
...remainingResults.flatMap((result) => result.resourceUids),
...firstPage.findingIds,
...remainingResults.flatMap((result) => result.findingIds),
]),
);
return resolveFindingIds({
checkId,
resourceUids,
filters,
hasDateOrScanFilter,
});
} catch (error) {
console.error(
"Error resolving finding IDs from visible group resources:",
@@ -381,7 +264,7 @@ export const getLatestFindingsByResourceUid = async ({
url.searchParams.append("filter[resource_uid]", resourceUid);
url.searchParams.append("filter[status]", "FAIL");
url.searchParams.append("filter[muted]", "include");
url.searchParams.append("sort", "-severity,status,-updated_at");
url.searchParams.append("sort", "-severity,-updated_at");
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
@@ -7,6 +7,7 @@ import {
getComplianceRequirements,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScan } from "@/actions/scans";
import {
ClientAccordionWrapper,
ComplianceDownloadContainer,
@@ -37,7 +38,6 @@ interface ComplianceDetailSearchParams {
complianceId: string;
version?: string;
scanId?: string;
scanData?: string;
"filter[region__in]"?: string;
"filter[cis_profile_level]"?: string;
page?: string;
@@ -53,7 +53,7 @@ export default async function ComplianceDetail({
}) {
const { compliancetitle } = await params;
const resolvedSearchParams = await searchParams;
const { complianceId, version, scanId, scanData } = resolvedSearchParams;
const { complianceId, version, scanId } = resolvedSearchParams;
const regionFilter = resolvedSearchParams["filter[region__in]"];
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
const logoPath = getComplianceIcon(compliancetitle);
@@ -72,21 +72,34 @@ export default async function ComplianceDetail({
: `${formattedTitle}`;
let selectedScan: ScanEntity | null = null;
const selectedScanId = scanId || null;
if (scanData) {
selectedScan = JSON.parse(decodeURIComponent(scanData));
}
const [metadataInfoData, attributesData, selectedScanResponse] =
await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
},
}),
getComplianceAttributes(complianceId),
selectedScanId ? getScan(selectedScanId) : Promise.resolve(null),
]);
const selectedScanId = scanId || selectedScan?.id || null;
const [metadataInfoData, attributesData] = await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
if (selectedScanResponse?.data) {
const scan = selectedScanResponse.data;
selectedScan = {
id: scan.id,
providerInfo: {
provider: scan.providerInfo?.provider || "aws",
alias: scan.providerInfo?.alias,
uid: scan.providerInfo?.uid,
},
}),
getComplianceAttributes(complianceId),
]);
attributes: {
name: scan.attributes.name,
completed_at: scan.attributes.completed_at,
},
};
}
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
@@ -32,7 +32,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
scanId,
complianceId,
id,
selectedScan,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -65,17 +64,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
params.set("version", version);
params.set("scanId", scanId);
if (selectedScan) {
params.set(
"scanData",
JSON.stringify({
id: selectedScan.id,
providerInfo: selectedScan.providerInfo,
attributes: selectedScan.attributes,
}),
);
}
const regionFilter = searchParams.get("filter[region__in]");
if (regionFilter) {
params.set("filter[region__in]", regionFilter);
@@ -40,7 +40,6 @@ export const ThreatScoreBadge = ({
score,
scanId,
provider,
selectedScan,
sectionScores,
}: ThreatScoreBadgeProps) => {
const router = useRouter();
@@ -62,17 +61,6 @@ export const ThreatScoreBadge = ({
params.set("version", version);
params.set("scanId", scanId);
if (selectedScan) {
params.set(
"scanData",
JSON.stringify({
id: selectedScan.id,
providerInfo: selectedScan.providerInfo,
attributes: selectedScan.attributes,
}),
);
}
const regionFilter = searchParams.get("filter[region__in]");
if (regionFilter) {
params.set("filter[region__in]", regionFilter);
+13 -1
View File
@@ -86,7 +86,14 @@ export const FindingsFilters = ({
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
const customFilters = [
...filterFindings,
...filterFindings.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
}),
})),
{
key: FilterType.REGION,
labelCheckboxGroup: "Regions",
@@ -124,6 +131,11 @@ export const FindingsFilters = ({
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, {
providers,
scans: scanDetails,
}),
index: 7,
},
];
@@ -101,6 +101,24 @@ describe("getFindingsFilterDisplayValue", () => {
).toBe("Scan Account");
});
it("normalizes finding statuses for display", () => {
expect(getFindingsFilterDisplayValue("filter[status__in]", "FAIL")).toBe(
"Fail",
);
});
it("normalizes severities for display", () => {
expect(
getFindingsFilterDisplayValue("filter[severity__in]", "critical"),
).toBe("Critical");
});
it("formats delta values for display", () => {
expect(getFindingsFilterDisplayValue("filter[delta__in]", "new")).toBe(
"New",
);
});
it("falls back to the scan provider uid when the alias is missing", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
@@ -9,6 +9,11 @@ interface GetFindingsFilterDisplayValueOptions {
scans?: Array<{ [scanId: string]: ScanEntity }>;
}
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
new: "New",
changed: "Changed",
};
function getProviderAccountDisplayValue(
providerId: string,
providers: ProviderProps[],
@@ -62,6 +67,11 @@ export function getFindingsFilterDisplayValue(
] ?? formatLabel(value)
);
}
if (filterKey === "filter[delta__in]") {
return (
FINDING_DELTA_DISPLAY_NAMES[value.toLowerCase()] ?? formatLabel(value)
);
}
if (filterKey === "filter[category__in]") {
return getCategoryLabel(value);
}
@@ -24,6 +24,17 @@ vi.mock("next/navigation", () => ({
import { FloatingMuteButton } from "./floating-mute-button";
function deferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
// ---------------------------------------------------------------------------
// Fix 3: onBeforeOpen rejection resets isResolving
// ---------------------------------------------------------------------------
@@ -31,7 +42,6 @@ import { FloatingMuteButton } from "./floating-mute-button";
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
@@ -58,11 +68,9 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
});
});
it("should log the error when onBeforeOpen rejects", async () => {
it("should show the preparation error in the modal when onBeforeOpen rejects", async () => {
// Given
const error = new Error("Fetch failed");
const onBeforeOpen = vi.fn().mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Fetch failed"));
const user = userEvent.setup();
render(
@@ -76,9 +84,26 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// When
await user.click(screen.getByRole("button"));
// Then — error was logged
// Then
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalled();
const lastCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
isPreparing?: boolean;
preparationError?: string | null;
},
]
>
).at(-1);
expect(lastCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: false,
preparationError:
"We couldn't prepare this mute action. Please try again.",
});
});
});
@@ -102,10 +127,76 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
await waitFor(() => {
const lastCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[{ isOpen: boolean; findingIds: string[] }]
[
{
isOpen: boolean;
findingIds: string[];
isPreparing?: boolean;
},
]
>
).at(-1);
expect(lastCall?.[0]?.isOpen).toBe(true);
});
});
it("should open the modal immediately in preparing state while IDs are still resolving", async () => {
// Given
const deferred = deferredPromise<string[]>();
const onBeforeOpen = vi.fn().mockReturnValue(deferred.promise);
const user = userEvent.setup();
render(
<FloatingMuteButton
selectedCount={3}
selectedFindingIds={["group-1", "group-2", "group-3"]}
onBeforeOpen={onBeforeOpen}
/>,
);
// When
await user.click(screen.getByRole("button"));
// Then
const preparingCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
findingIds: string[];
isPreparing?: boolean;
},
]
>
).at(-1);
expect(preparingCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: true,
findingIds: [],
});
// And when the IDs resolve
deferred.resolve(["id-1", "id-2"]);
await waitFor(() => {
const resolvedCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
findingIds: string[];
isPreparing?: boolean;
},
]
>
).at(-1);
expect(resolvedCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: false,
findingIds: ["id-1", "id-2"],
});
});
});
});
@@ -31,22 +31,46 @@ export function FloatingMuteButton({
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const handleModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
) => {
const resolvedOpen =
typeof nextOpen === "function" ? nextOpen(isModalOpen) : nextOpen;
setIsModalOpen(resolvedOpen);
if (!resolvedOpen) {
setResolvedIds([]);
setIsPreparingMuteModal(false);
setMutePreparationError(null);
}
};
const handleClick = async () => {
if (onBeforeOpen) {
setResolvedIds([]);
setMutePreparationError(null);
setIsPreparingMuteModal(true);
setIsModalOpen(true);
setIsResolving(true);
try {
const ids = await onBeforeOpen();
setResolvedIds(ids);
if (ids.length > 0) {
setIsModalOpen(true);
}
} catch (error) {
console.error(
"FloatingMuteButton: failed to resolve finding IDs",
error,
setMutePreparationError(
ids.length === 0
? "No findings could be resolved for this selection. Try refreshing the page and trying again."
: null,
);
} catch {
setMutePreparationError(
"We couldn't prepare this mute action. Please try again.",
);
} finally {
setIsPreparingMuteModal(false);
setIsResolving(false);
}
} else {
@@ -65,10 +89,12 @@ export function FloatingMuteButton({
<>
<MuteFindingsModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
onOpenChange={handleModalOpenChange}
findingIds={findingIds}
onComplete={handleComplete}
isBulkOperation={isBulkOperation}
isPreparing={isPreparingMuteModal}
preparationError={mutePreparationError}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
+129 -43
View File
@@ -5,7 +5,10 @@ import { Dispatch, SetStateAction, useState, useTransition } from "react";
import { createMuteRule } from "@/actions/mute-rules";
import { MuteRuleActionState } from "@/actions/mute-rules/types";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useToast } from "@/components/ui";
import { FormButtons } from "@/components/ui/form";
@@ -15,6 +18,8 @@ interface MuteFindingsModalProps {
findingIds: string[];
onComplete?: () => void;
isBulkOperation?: boolean;
isPreparing?: boolean;
preparationError?: string | null;
}
export function MuteFindingsModal({
@@ -23,6 +28,8 @@ export function MuteFindingsModal({
findingIds,
onComplete,
isBulkOperation = false,
isPreparing = false,
preparationError = null,
}: MuteFindingsModalProps) {
const { toast } = useToast();
const [state, setState] = useState<MuteRuleActionState | null>(null);
@@ -32,6 +39,12 @@ export function MuteFindingsModal({
onOpenChange(false);
};
const isSubmitDisabled =
isPending ||
isPreparing ||
findingIds.length === 0 ||
Boolean(preparationError);
return (
<Modal
open={isOpen}
@@ -43,6 +56,10 @@ export function MuteFindingsModal({
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
if (isSubmitDisabled) {
return;
}
const formData = new FormData(e.currentTarget);
startTransition(() => {
@@ -77,52 +94,121 @@ export function MuteFindingsModal({
value={JSON.stringify(findingIds)}
/>
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<p className="text-sm text-slate-600 dark:text-slate-400">
You are about to mute{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{findingIds.length}
</span>{" "}
{findingIds.length === 1 ? "finding" : "findings"}.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
Muted findings will be hidden by default but can be shown using
filters.
</p>
</div>
{isPreparing ? (
<>
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
<div className="flex items-start gap-3">
<Spinner className="mt-0.5 size-5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900 dark:text-white">
Preparing findings to mute...
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Large finding groups can take a few seconds while we gather
the matching findings.
</p>
</div>
</div>
</div>
<Input
name="name"
label="Rule Name"
placeholder="e.g., Ignore dev environment S3 buckets"
isRequired
variant="bordered"
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isDisabled={isPending}
description="A descriptive name for this mute rule"
/>
<div className="space-y-3" aria-hidden="true">
<div className="space-y-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-11 w-full rounded-lg" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20 rounded" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
</div>
<Textarea
name="reason"
label="Reason"
placeholder="e.g., These are expected findings in the development environment"
isRequired
variant="bordered"
minRows={3}
maxRows={6}
isInvalid={!!state?.errors?.reason}
errorMessage={state?.errors?.reason}
isDisabled={isPending}
description="Explain why these findings are being muted"
/>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleCancel}
>
Cancel
</Button>
<Button type="button" size="lg" disabled>
<Spinner className="size-4" />
Preparing...
</Button>
</div>
</>
) : preparationError ? (
<>
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
<p className="text-sm font-medium text-slate-900 dark:text-white">
We couldn&apos;t prepare this mute action.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{preparationError}
</p>
</div>
<FormButtons
setIsOpen={onOpenChange}
onCancel={handleCancel}
submitText="Mute Findings"
isDisabled={isPending}
/>
<div className="flex w-full justify-end">
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleCancel}
>
Close
</Button>
</div>
</>
) : (
<>
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<p className="text-sm text-slate-600 dark:text-slate-400">
You are about to mute{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{findingIds.length}
</span>{" "}
{findingIds.length === 1 ? "finding" : "findings"}.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
Muted findings will be hidden by default but can be shown using
filters.
</p>
</div>
<Input
name="name"
label="Rule Name"
placeholder="e.g., Ignore dev environment S3 buckets"
isRequired
variant="bordered"
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isDisabled={isPending}
description="A descriptive name for this mute rule"
/>
<Textarea
name="reason"
label="Reason"
placeholder="e.g., These are expected findings in the development environment"
isRequired
variant="bordered"
minRows={3}
maxRows={6}
isInvalid={!!state?.errors?.reason}
errorMessage={state?.errors?.reason}
isDisabled={isPending}
description="Explain why these findings are being muted"
/>
<FormButtons
setIsOpen={onOpenChange}
onCancel={handleCancel}
submitText="Mute Findings"
isDisabled={isPending}
/>
</>
)}
</form>
</Modal>
);
@@ -3,6 +3,10 @@ import userEvent from "@testing-library/user-event";
import type { InputHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
const { notificationIndicatorMock } = vi.hoisted(() => ({
notificationIndicatorMock: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Hoist mocks for dependencies
// ---------------------------------------------------------------------------
@@ -72,7 +76,10 @@ vi.mock("./impacted-resources-cell", () => ({
vi.mock("./notification-indicator", () => ({
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
NotificationIndicator: () => null,
NotificationIndicator: (props: unknown) => {
notificationIndicatorMock(props);
return null;
},
}));
// ---------------------------------------------------------------------------
@@ -95,10 +102,23 @@ function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
checkTitle: "S3 Bucket Public Access",
severity: "critical",
status: "FAIL",
muted: false,
resourcesTotal: 5,
resourcesFail: 3,
newCount: 0,
changedCount: 0,
newFailCount: 0,
newFailMutedCount: 0,
newPassCount: 0,
newPassMutedCount: 0,
newManualCount: 0,
newManualMutedCount: 0,
changedFailCount: 0,
changedFailMutedCount: 0,
changedPassCount: 0,
changedPassMutedCount: 0,
changedManualCount: 0,
changedManualMutedCount: 0,
mutedCount: 0,
providers: ["aws"],
updatedAt: "2024-01-01T00:00:00Z",
@@ -325,3 +345,25 @@ describe("column-finding-groups — group selection", () => {
expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled();
});
});
describe("column-finding-groups — indicators", () => {
it("should prefer the new indicator when the new delta exists only in the breakdown fields", () => {
notificationIndicatorMock.mockClear();
renderSelectCell({
muted: true,
newCount: 0,
changedCount: 0,
newFailMutedCount: 1,
changedFailCount: 2,
});
expect(notificationIndicatorMock).toHaveBeenCalledWith(
expect.objectContaining({
delta: "new",
isMuted: true,
showDeltaWhenMuted: true,
}),
);
});
});
@@ -10,6 +10,10 @@ import {
StatusFindingBadge,
} from "@/components/ui/table";
import { cn } from "@/lib";
import {
getFindingGroupDelta,
isFindingGroupMuted,
} from "@/lib/findings-groups";
import { FindingGroupRow, ProviderType } from "@/types";
import { DataTableRowActions } from "./data-table-row-actions";
@@ -74,14 +78,13 @@ export function getColumnFindingGroups({
},
cell: ({ row }) => {
const group = row.original;
const allMuted =
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
const allMuted = isFindingGroupMuted(group);
const isExpanded = expandedCheckId === group.checkId;
const deltaKey = getFindingGroupDelta(group);
const delta =
group.newCount > 0
deltaKey === "new"
? DeltaValues.NEW
: group.changedCount > 0
: deltaKey === "changed"
? DeltaValues.CHANGED
: DeltaValues.NONE;
@@ -89,12 +92,17 @@ export function getColumnFindingGroups({
const canSelect = canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
muted: group.muted,
mutedCount: group.mutedCount,
});
return (
<div className="flex items-center gap-2">
<NotificationIndicator delta={delta} isMuted={allMuted} />
<NotificationIndicator
delta={delta}
isMuted={allMuted}
showDeltaWhenMuted
/>
{canExpand ? (
<button
type="button"
@@ -149,9 +157,7 @@ export function getColumnFindingGroups({
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const rawStatus = row.original.status as string;
const status = rawStatus === "MUTED" ? "FAIL" : row.original.status;
return <StatusFindingBadge status={status} />;
return <StatusFindingBadge status={row.original.status} />;
},
enableSorting: false,
},
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { InputHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
@@ -26,7 +27,19 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
SendToJiraModal: ({
findingId,
isOpen,
}: {
findingId: string;
isOpen: boolean;
}) => (
<div
data-testid="jira-modal"
data-finding-id={findingId}
data-open={isOpen ? "true" : "false"}
/>
),
}));
vi.mock("@/components/icons/services/IconServices", () => ({
@@ -37,8 +50,18 @@ vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: ({ label }: { label: string }) => (
<button>{label}</button>
ActionDropdownItem: ({
label,
onSelect,
disabled,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button disabled={disabled} onClick={onSelect}>
{label}
</button>
),
}));
@@ -200,4 +223,50 @@ describe("column-finding-resources", () => {
expect(screen.getByText("my-bucket")).toBeInTheDocument();
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
});
it("should open Send to Jira modal with finding UUID directly", async () => {
// Given
const user = userEvent.setup();
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const actionColumn = columns.find(
(col) => (col as { id?: string }).id === "actions",
);
if (!actionColumn?.cell) {
throw new Error("actions column not found");
}
const CellComponent = actionColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
render(
<div>
{CellComponent({
row: {
original: makeResource({
findingId: "real-finding-uuid",
}),
},
})}
</div>,
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
expect(screen.getByTestId("jira-modal")).toHaveAttribute(
"data-finding-id",
"real-finding-uuid",
);
expect(screen.getByTestId("jira-modal")).toHaveAttribute(
"data-open",
"true",
);
});
});
@@ -66,6 +66,14 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
// Single resource: findingId is already a real finding UUID
if (displayIds.length === 1) {
setResolvedIds(displayIds);
setIsMuteModalOpen(true);
return;
}
// Multi-select: resolve through context
if (resolveMuteIds) {
setIsResolving(true);
const ids = await resolveMuteIds(displayIds);
@@ -179,6 +187,7 @@ export function getColumnFindingResources({
delta={row.original.delta as DeltaType | undefined}
isMuted={row.original.isMuted}
mutedReason={row.original.mutedReason}
showDeltaWhenMuted
/>
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
<Checkbox
@@ -218,14 +227,9 @@ export function getColumnFindingResources({
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const rawStatus = row.original.status;
const status: FindingStatus =
rawStatus === "MUTED" || rawStatus === "FAIL"
? "FAIL"
: rawStatus === "PASS"
? "PASS"
: "FAIL";
return <StatusFindingBadge status={status} />;
return (
<StatusFindingBadge status={row.original.status as FindingStatus} />
);
},
enableSorting: false,
},
@@ -97,6 +97,7 @@ export function getColumnFindings(
delta={delta}
isMuted={isMuted}
mutedReason={mutedReason}
showDeltaWhenMuted
/>
);
},
@@ -0,0 +1,182 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { MuteFindingsModalMock } = vi.hoisted(() => ({
MuteFindingsModalMock: vi.fn(() => null),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: MuteFindingsModalMock,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: ({
label,
onSelect,
disabled,
}: {
label: string;
onSelect: () => void;
disabled?: boolean;
}) => (
<button onClick={onSelect} disabled={disabled}>
{label}
</button>
),
}));
vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => <span>Loading</span>,
}));
import { DataTableRowActions } from "./data-table-row-actions";
import { FindingsSelectionContext } from "./findings-selection-context";
function deferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("DataTableRowActions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens the mute modal immediately in preparing state for finding groups", async () => {
// Given
const deferred = deferredPromise<string[]>();
const resolveMuteIds = vi.fn().mockReturnValue(deferred.promise);
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds,
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "ecs_task_definitions_no_environment_secrets",
checkTitle: "ECS task definitions no environment secrets",
mutedCount: 0,
resourcesFail: 475,
resourcesTotal: 475,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Mute Finding Group" }),
);
// Then
const preparingCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
isPreparing?: boolean;
findingIds: string[];
},
]
>
).at(-1);
expect(preparingCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: true,
findingIds: [],
});
// And when the resolver finishes
deferred.resolve(["finding-1", "finding-2"]);
await waitFor(() => {
const resolvedCall = (
MuteFindingsModalMock.mock.calls as unknown as Array<
[
{
isOpen: boolean;
isPreparing?: boolean;
findingIds: string[];
},
]
>
).at(-1);
expect(resolvedCall?.[0]).toMatchObject({
isOpen: true,
isPreparing: false,
findingIds: ["finding-1", "finding-2"],
});
});
});
it("disables the mute action for groups without impacted resources", () => {
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-2",
rowType: "group",
checkId: "check-with-zero-failures",
checkTitle: "Check with zero failures",
mutedCount: 0,
resourcesFail: 0,
resourcesTotal: 42,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
expect(
screen.getByRole("button", { name: "Mute Finding Group" }),
).toBeDisabled();
});
});
@@ -13,7 +13,9 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { isFindingGroupMuted } from "@/lib/findings-groups";
import { canMuteFindingGroup } from "./finding-group-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
export interface FindingRowData {
@@ -28,7 +30,9 @@ export interface FindingRowData {
rowType?: string;
checkId?: string;
checkTitle?: string;
muted?: boolean;
mutedCount?: number;
resourcesFail?: number;
resourcesTotal?: number;
}
@@ -38,15 +42,27 @@ export interface FindingRowData {
*/
function extractRowInfo(data: FindingRowData) {
if (data.rowType === "group") {
const allMuted =
(data.mutedCount ?? 0) > 0 && data.mutedCount === data.resourcesTotal;
const isMuted = isFindingGroupMuted({
muted: data.muted,
mutedCount: data.mutedCount ?? 0,
resourcesFail: data.resourcesFail ?? 0,
resourcesTotal: data.resourcesTotal ?? 0,
});
return {
isMuted: allMuted,
isMuted,
canMute: canMuteFindingGroup({
resourcesFail: data.resourcesFail ?? 0,
resourcesTotal: data.resourcesTotal ?? 0,
muted: data.muted,
mutedCount: data.mutedCount ?? 0,
}),
title: data.checkTitle || "Security Finding",
};
}
return {
isMuted: data.attributes?.muted ?? false,
canMute: !(data.attributes?.muted ?? false),
title: data.attributes?.check_metadata?.checktitle || "Security Finding",
};
}
@@ -64,8 +80,12 @@ export function DataTableRowActions<T extends FindingRowData>({
const finding = row.original;
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const { isMuted, title: findingTitle } = extractRowInfo(finding);
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
// Get selection context - if there are other selected rows, include them
const selectionContext = useContext(FindingsSelectionContext);
@@ -103,15 +123,45 @@ export function DataTableRowActions<T extends FindingRowData>({
return isGroup ? "Mute Finding Group" : "Mute Finding";
};
const handleMuteModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
) => {
const resolvedOpen =
typeof nextOpen === "function" ? nextOpen(isMuteModalOpen) : nextOpen;
setIsMuteModalOpen(resolvedOpen);
if (!resolvedOpen) {
setIsPreparingMuteModal(false);
setMutePreparationError(null);
setResolvedIds([]);
}
};
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
if (resolveMuteIds) {
setResolvedIds([]);
setMutePreparationError(null);
setIsPreparingMuteModal(true);
setIsMuteModalOpen(true);
setIsResolving(true);
const ids = await resolveMuteIds(displayIds);
setResolvedIds(ids);
setIsResolving(false);
if (ids.length > 0) setIsMuteModalOpen(true);
try {
const ids = await resolveMuteIds(displayIds);
setResolvedIds(ids);
setMutePreparationError(
ids.length === 0
? "No findings could be resolved for this group. Try refreshing the page and trying again."
: null,
);
} catch {
setMutePreparationError(
"We couldn't prepare this mute action. Please try again.",
);
} finally {
setIsPreparingMuteModal(false);
setIsResolving(false);
}
} else {
// Regular findings — IDs are already valid finding UUIDs
setResolvedIds(displayIds);
@@ -146,10 +196,12 @@ export function DataTableRowActions<T extends FindingRowData>({
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
onOpenChange={handleMuteModalOpenChange}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
isBulkOperation={finding.rowType === "group"}
isPreparing={isPreparingMuteModal}
preparationError={mutePreparationError}
/>
<div className="flex items-center justify-end">
@@ -165,7 +217,7 @@ export function DataTableRowActions<T extends FindingRowData>({
)
}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={isMuted || isResolving}
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
{!isGroup && (
@@ -7,23 +7,21 @@ describe("canMuteFindingGroup", () => {
expect(
canMuteFindingGroup({
resourcesFail: 0,
resourcesTotal: 2,
mutedCount: 0,
muted: false,
}),
).toBe(false);
});
it("returns false when all resources are already muted", () => {
it("returns false when the explicit muted flag marks the group as fully muted", () => {
expect(
canMuteFindingGroup({
resourcesFail: 3,
resourcesTotal: 3,
mutedCount: 3,
muted: true,
}),
).toBe(false);
});
it("returns false when all failing resources are muted even if PASS resources exist", () => {
it("returns false when legacy counters indicate all failing resources are muted", () => {
expect(
canMuteFindingGroup({
resourcesFail: 2,
@@ -37,8 +35,7 @@ describe("canMuteFindingGroup", () => {
expect(
canMuteFindingGroup({
resourcesFail: 2,
resourcesTotal: 5,
mutedCount: 1,
muted: false,
}),
).toBe(true);
});
@@ -1,13 +1,25 @@
import { isFindingGroupMuted } from "@/lib/findings-groups";
interface FindingGroupSelectionState {
resourcesFail: number;
resourcesTotal: number;
mutedCount: number;
resourcesTotal?: number;
muted?: boolean;
mutedCount?: number;
}
export function canMuteFindingGroup({
resourcesFail,
muted,
mutedCount,
resourcesTotal,
}: FindingGroupSelectionState): boolean {
const allMuted = mutedCount > 0 && mutedCount === resourcesFail;
return resourcesFail > 0 && !allMuted;
return (
resourcesFail > 0 &&
!isFindingGroupMuted({
muted,
mutedCount: mutedCount ?? 0,
resourcesFail,
resourcesTotal: resourcesTotal ?? 0,
})
);
}
@@ -11,7 +11,6 @@ import { ChevronLeft } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
Table,
@@ -24,6 +23,10 @@ import {
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
import { cn, hasDateOrScanFilter } from "@/lib";
import {
getFindingGroupDelta,
isFindingGroupMuted,
} from "@/lib/findings-groups";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
@@ -111,18 +114,9 @@ export function FindingsGroupDrillDown({
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter((id): id is string => id !== null && id !== undefined && id !== "");
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
/** findingId values are already real finding UUIDs — no resolution needed. */
const resolveResourceIds = async (ids: string[]) => {
const resourceUids = ids
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
.filter(Boolean) as string[];
if (resourceUids.length === 0) return [];
return resolveFindingIds({
checkId: group.checkId,
resourceUids,
filters,
hasDateOrScanFilter: hasDateOrScan,
});
return ids.filter(Boolean);
};
const selectableRowCount = resources.filter(canMuteFindingResource).length;
@@ -162,15 +156,15 @@ export function FindingsGroupDrillDown({
});
// Delta for the sticky header
const deltaKey = getFindingGroupDelta(group);
const delta =
group.newCount > 0
deltaKey === "new"
? DeltaValues.NEW
: group.changedCount > 0
: deltaKey === "changed"
? DeltaValues.CHANGED
: DeltaValues.NONE;
const allMuted =
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
const allMuted = isFindingGroupMuted(group);
const rows = table.getRowModel().rows;
@@ -205,7 +199,11 @@ export function FindingsGroupDrillDown({
</button>
{/* Notification indicator */}
<NotificationIndicator delta={delta} isMuted={allMuted} />
<NotificationIndicator
delta={delta}
isMuted={allMuted}
showDeltaWhenMuted
/>
{/* Status badge */}
<StatusFindingBadge status={group.status} />
@@ -4,10 +4,7 @@ import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import {
resolveFindingIds,
resolveFindingIdsByVisibleGroupResources,
} from "@/actions/findings/findings-by-resource";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { DataTable } from "@/components/ui/table";
import { hasDateOrScanFilter } from "@/lib";
import { FindingGroupRow, MetaDataProps } from "@/types";
@@ -93,6 +90,7 @@ export function FindingsGroupTable({
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
resourcesTotal: g.resourcesTotal,
muted: g.muted,
mutedCount: g.mutedCount,
}),
).length;
@@ -102,6 +100,7 @@ export function FindingsGroupTable({
return canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
muted: group.muted,
mutedCount: group.mutedCount,
});
};
@@ -238,14 +237,8 @@ export function FindingsGroupTable({
selectedCheckIds.length > 0
? resolveGroupMuteIds(selectedCheckIds)
: Promise.resolve([]),
hasResourceSelection && expandedCheckId
? resolveFindingIds({
checkId: expandedCheckId,
resourceUids: resourceSelection,
filters,
hasDateOrScanFilter: hasDateOrScan,
})
: Promise.resolve([]),
// resourceSelection already contains real finding UUIDs
Promise.resolve(hasResourceSelection ? resourceSelection : []),
]);
return [...groupIds, ...resourceIds];
}}
@@ -12,7 +12,6 @@ import { ChevronsDown } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useImperativeHandle, useRef, useState } from "react";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TableCell, TableRow } from "@/components/ui/table";
@@ -24,6 +23,10 @@ import { FindingGroupRow, FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
getFilteredFindingGroupResourceCount,
getFindingGroupSkeletonCount,
} from "./inline-resource-container.utils";
import {
ResourceDetailDrawer,
useResourceDetailDrawer,
@@ -40,8 +43,8 @@ interface InlineResourceContainerProps {
group: FindingGroupRow;
resourceSearch: string;
columnCount: number;
/** Called with selected resource UIDs (not finding IDs) for parent-level mute resolution */
onResourceSelectionChange: (resourceUids: string[]) => void;
/** Called with selected finding IDs (real UUIDs) for parent-level mute */
onResourceSelectionChange: (findingIds: string[]) => void;
ref?: React.Ref<InlineResourceContainerHandle>;
}
@@ -55,11 +58,17 @@ interface InlineResourceContainerProps {
/** Max skeleton rows that fit in the 440px scroll container */
const MAX_SKELETON_ROWS = 7;
function ResourceSkeletonRow() {
function ResourceSkeletonRow({
isEmptyStateSized = false,
}: {
isEmptyStateSized?: boolean;
}) {
const cellClassName = isEmptyStateSized ? "h-24 py-3" : "py-3";
return (
<TableRow className="hover:bg-transparent">
{/* Select: indicator + corner arrow + checkbox */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-1.5 rounded-full" />
<Skeleton className="size-4 rounded" />
@@ -67,55 +76,55 @@ function ResourceSkeletonRow() {
</div>
</TableCell>
{/* Resource: icon + name + uid */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<div className="space-y-1">
<Skeleton className="h-3.5 w-32 rounded" />
<Skeleton className="h-3 w-20 rounded" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3.5 w-20 rounded" />
</div>
</div>
</TableCell>
{/* Status */}
<TableCell>
<TableCell className={cellClassName}>
<Skeleton className="h-6 w-11 rounded-md" />
</TableCell>
{/* Service */}
<TableCell>
<Skeleton className="h-4 w-16 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-16 rounded" />
</TableCell>
{/* Region */}
<TableCell>
<Skeleton className="h-4 w-20 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-20 rounded" />
</TableCell>
{/* Severity */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full" />
<Skeleton className="h-4 w-12 rounded" />
<Skeleton className="h-4.5 w-12 rounded" />
</div>
</TableCell>
{/* Account: provider icon + alias + uid */}
<TableCell>
<TableCell className={cellClassName}>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<div className="space-y-1">
<Skeleton className="h-3.5 w-24 rounded" />
<Skeleton className="h-3 w-16 rounded" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-3.5 w-16 rounded" />
</div>
</div>
</TableCell>
{/* Last seen */}
<TableCell>
<Skeleton className="h-4 w-24 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-24 rounded" />
</TableCell>
{/* Failing for */}
<TableCell>
<Skeleton className="h-4 w-16 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-16 rounded" />
</TableCell>
{/* Actions */}
<TableCell>
<Skeleton className="size-6 rounded" />
<TableCell className={cellClassName}>
<Skeleton className="size-8 rounded-md" />
</TableCell>
</TableRow>
);
@@ -161,6 +170,16 @@ export function InlineResourceContainer({
filters["filter[name__icontains]"] = resourceSearch;
}
const skeletonRowCount = getFindingGroupSkeletonCount(
group,
filters,
MAX_SKELETON_ROWS,
);
const filteredResourceCount = getFilteredFindingGroupResourceCount(
group,
filters,
);
const handleSetResources = (
newResources: FindingResourceRow[],
_hasMore: boolean,
@@ -211,16 +230,9 @@ export function InlineResourceContainer({
.filter(Boolean);
const resolveResourceIds = async (ids: string[]) => {
const resourceUids = ids
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
.filter(Boolean) as string[];
if (resourceUids.length === 0) return [];
return resolveFindingIds({
checkId: group.checkId,
resourceUids,
filters,
hasDateOrScanFilter: hasDateOrScan,
});
// findingId values are already real finding UUIDs (from the group
// resources endpoint), so no second resolution round-trip is needed.
return ids.filter(Boolean);
};
const selectableRowCount = resources.filter(canMuteFindingResource).length;
@@ -254,11 +266,11 @@ export function InlineResourceContainer({
typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(newSelection);
const newResourceUids = Object.keys(newSelection)
const newFindingIds = Object.keys(newSelection)
.filter((key) => newSelection[key])
.map((idx) => resources[parseInt(idx)]?.resourceUid)
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter(Boolean);
onResourceSelectionChange(newResourceUids);
onResourceSelectionChange(newFindingIds);
};
const columns = getColumnFindingResources({
@@ -310,12 +322,12 @@ export function InlineResourceContainer({
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({
length: Math.min(
group.resourcesTotal,
MAX_SKELETON_ROWS,
),
}).map((_, i) => <ResourceSkeletonRow key={i} />)
Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))
) : rows.length > 0 ? (
rows.map((row) => (
<TableRow
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { FindingGroupRow } from "@/types";
import {
getFilteredFindingGroupResourceCount,
getFindingGroupSkeletonCount,
isFailOnlyStatusFilter,
} from "./inline-resource-container.utils";
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
return {
id: "group-1",
rowType: "group",
checkId: "check-1",
checkTitle: "Test finding group",
severity: "high",
status: "FAIL",
resourcesTotal: 20,
resourcesFail: 6,
newCount: 0,
changedCount: 0,
mutedCount: 0,
providers: ["aws"],
updatedAt: "2026-04-09T00:00:00Z",
...overrides,
};
}
describe("isFailOnlyStatusFilter", () => {
it("returns true when filter[status__in] only contains FAIL", () => {
expect(
isFailOnlyStatusFilter({
"filter[status__in]": "FAIL",
}),
).toBe(true);
});
it("returns false when filter[status__in] includes more than FAIL", () => {
expect(
isFailOnlyStatusFilter({
"filter[status__in]": "FAIL,PASS",
}),
).toBe(false);
});
it("returns true when filter[status] is FAIL", () => {
expect(
isFailOnlyStatusFilter({
"filter[status]": "FAIL",
}),
).toBe(true);
});
});
describe("getFilteredFindingGroupResourceCount", () => {
it("returns zero filtered resources when FAIL is the only active status and none fail", () => {
expect(
getFilteredFindingGroupResourceCount(
makeGroup({ resourcesFail: 0, resourcesTotal: 4 }),
{ "filter[status__in]": "FAIL" },
),
).toBe(0);
});
});
describe("getFindingGroupSkeletonCount", () => {
it("uses the total resource count when FAIL is not the only active status", () => {
expect(getFindingGroupSkeletonCount(makeGroup(), {}, 7)).toBe(7);
});
it("uses the failing resource count when FAIL is the only active status", () => {
expect(
getFindingGroupSkeletonCount(
makeGroup(),
{ "filter[status__in]": "FAIL" },
7,
),
).toBe(6);
});
it("still caps the skeleton count to the configured maximum", () => {
expect(
getFindingGroupSkeletonCount(
makeGroup({ resourcesFail: 15 }),
{ "filter[status__in]": "FAIL" },
7,
),
).toBe(7);
});
it("reserves one skeleton row when the filtered resource count is zero", () => {
expect(
getFindingGroupSkeletonCount(
makeGroup({ resourcesFail: 0, resourcesTotal: 0 }),
{ "filter[status__in]": "FAIL" },
7,
),
).toBe(1);
});
});
@@ -0,0 +1,55 @@
import { FindingGroupRow } from "@/types";
function parseStatusFilterValue(statusFilterValue?: string): string[] {
if (!statusFilterValue) {
return [];
}
return statusFilterValue
.split(",")
.map((status) => status.trim().toUpperCase())
.filter(Boolean);
}
export function isFailOnlyStatusFilter(
filters: Record<string, string | string[] | undefined>,
): boolean {
const directStatusValues = parseStatusFilterValue(
typeof filters["filter[status]"] === "string"
? filters["filter[status]"]
: undefined,
);
if (directStatusValues.length > 0) {
return directStatusValues.length === 1 && directStatusValues[0] === "FAIL";
}
const multiStatusValues = parseStatusFilterValue(
typeof filters["filter[status__in]"] === "string"
? filters["filter[status__in]"]
: undefined,
);
return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL";
}
export function getFilteredFindingGroupResourceCount(
group: FindingGroupRow,
filters: Record<string, string | string[] | undefined>,
): number {
return isFailOnlyStatusFilter(filters)
? group.resourcesFail
: group.resourcesTotal;
}
export function getFindingGroupSkeletonCount(
group: FindingGroupRow,
filters: Record<string, string | string[] | undefined>,
maxSkeletonRows: number,
): number {
const filteredTotal = getFilteredFindingGroupResourceCount(group, filters);
// Reserve at least one row so the drill-down keeps visual space while the
// empty state ("No resources found") replaces the skeleton.
return Math.max(1, Math.min(filteredTotal, maxSkeletonRows));
}
@@ -30,14 +30,26 @@ interface NotificationIndicatorProps {
delta?: DeltaType;
isMuted?: boolean;
mutedReason?: string;
showDeltaWhenMuted?: boolean;
}
export const NotificationIndicator = ({
delta,
isMuted = false,
mutedReason,
showDeltaWhenMuted = false,
}: NotificationIndicatorProps) => {
// Muted takes precedence over delta.
const hasDelta = delta === DeltaValues.NEW || delta === DeltaValues.CHANGED;
if (isMuted && hasDelta && showDeltaWhenMuted) {
return (
<div className="flex shrink-0 items-center gap-1">
<MutedIndicator mutedReason={mutedReason} />
<DeltaIndicator delta={delta} />
</div>
);
}
// Uses Popover (not Tooltip) because the content has an interactive link.
// Radix Tooltip does not support interactive content — clicks fall through.
if (isMuted) {
@@ -45,56 +57,64 @@ export const NotificationIndicator = ({
}
// Show dot with tooltip for new or changed findings
if (delta === DeltaValues.NEW || delta === DeltaValues.CHANGED) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
>
<div
className={cn(
"size-1.5 rounded-full",
delta === DeltaValues.NEW
? "bg-system-severity-high"
: "bg-system-severity-low",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-1 text-xs">
<span>
{delta === DeltaValues.NEW
? "New finding."
: "Status changed since the previous scan."}
</span>
<Button
aria-label="Learn more about findings"
variant="link"
size="default"
className="text-button-primary h-auto min-w-0 p-0 text-xs"
asChild
>
<a
href={DOCS_URLS.FINDINGS_ANALYSIS}
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
</Button>
</div>
</TooltipContent>
</Tooltip>
);
if (hasDelta) {
return <DeltaIndicator delta={delta} />;
}
// No indicator - return minimal width placeholder
return <div className="w-2 shrink-0" />;
};
function DeltaIndicator({
delta,
}: {
delta: typeof DeltaValues.NEW | typeof DeltaValues.CHANGED;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
>
<div
className={cn(
"size-1.5 rounded-full",
delta === DeltaValues.NEW
? "bg-system-severity-high"
: "bg-system-severity-low",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-1 text-xs">
<span>
{delta === DeltaValues.NEW
? "New finding."
: "Status changed since the previous scan."}
</span>
<Button
aria-label="Learn more about findings"
variant="link"
size="default"
className="text-button-primary h-auto min-w-0 p-0 text-xs"
asChild
>
<a
href={DOCS_URLS.FINDINGS_ANALYSIS}
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
</Button>
</div>
</TooltipContent>
</Tooltip>
);
}
/** Muted indicator with hover-triggered Popover for interactive link. */
function MutedIndicator({ mutedReason }: { mutedReason?: string }) {
const [open, setOpen] = useState(false);
@@ -710,7 +710,7 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
scanId: "scan-from-finding",
});
expect(mockWindowOpen).toHaveBeenCalledWith(
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding",
"_blank",
"noopener,noreferrer",
);
@@ -786,7 +786,7 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
scanId: "scan-from-finding",
});
expect(mockWindowOpen).toHaveBeenCalledWith(
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding",
"_blank",
"noopener,noreferrer",
);
@@ -218,16 +218,12 @@ function buildComplianceDetailHref({
version,
scanId,
regionFilter,
currentFinding,
includeScanData,
}: {
complianceId: string;
framework: string;
version: string;
scanId: string;
regionFilter: string | null;
currentFinding: ResourceDrawerFinding | null;
includeScanData: boolean;
}): string {
const params = new URLSearchParams();
params.set("complianceId", complianceId);
@@ -240,24 +236,6 @@ function buildComplianceDetailHref({
params.set("filter[region__in]", regionFilter);
}
if (includeScanData && currentFinding?.scan?.completedAt) {
params.set(
"scanData",
JSON.stringify({
id: currentFinding.scan.id,
providerInfo: {
provider: currentFinding.providerType,
alias: currentFinding.providerAlias,
uid: currentFinding.providerUid,
},
attributes: {
name: currentFinding.scan.name,
completed_at: currentFinding.scan.completedAt,
},
}),
);
}
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
}
@@ -377,8 +355,6 @@ export function ResourceDetailDrawerContent({
version: complianceMatch.version,
scanId: complianceScanId,
regionFilter,
currentFinding: f,
includeScanData: f?.scan?.id === complianceScanId,
}),
"_blank",
"noopener,noreferrer",
@@ -54,6 +54,7 @@ export const getResourceFindingsColumns = (
delta={row.original.attributes.delta}
isMuted={row.original.attributes.muted}
mutedReason={row.original.attributes.muted_reason}
showDeltaWhenMuted
/>
</div>
),
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import type { FindingGroupRow } from "@/types";
import { getFindingGroupDelta, isFindingGroupMuted } from "./findings-groups";
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
return {
id: "group-1",
rowType: "group",
checkId: "check-1",
checkTitle: "Test finding group",
severity: "high",
status: "FAIL",
muted: false,
resourcesTotal: 5,
resourcesFail: 3,
newCount: 0,
changedCount: 0,
mutedCount: 0,
providers: ["aws"],
updatedAt: "2026-04-10T00:00:00Z",
...overrides,
};
}
describe("isFindingGroupMuted", () => {
it("should prefer the explicit muted flag from the API", () => {
expect(
isFindingGroupMuted(
makeGroup({
muted: true,
mutedCount: 0,
resourcesFail: 3,
resourcesTotal: 5,
}),
),
).toBe(true);
});
it("should fall back to legacy counters when muted is not available", () => {
expect(
isFindingGroupMuted(
makeGroup({
muted: undefined,
mutedCount: 3,
resourcesFail: 3,
}),
),
).toBe(true);
});
});
describe("getFindingGroupDelta", () => {
it("should return new when the muted breakdown contains new findings", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 0,
changedCount: 0,
newFailMutedCount: 1,
}),
),
).toBe("new");
});
it("should return changed when only changed breakdown counters are present", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 0,
changedCount: 0,
changedManualMutedCount: 2,
}),
),
).toBe("changed");
});
it("should prioritize new over changed when both breakdowns are present", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 0,
changedCount: 0,
newPassCount: 1,
changedFailCount: 3,
}),
),
).toBe("new");
});
it("should fall back to legacy counters when breakdowns are missing", () => {
expect(
getFindingGroupDelta(
makeGroup({
newCount: 1,
changedCount: 0,
}),
),
).toBe("new");
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { FindingGroupRow } from "@/types";
type FindingGroupMutedState = Pick<
FindingGroupRow,
"muted" | "mutedCount" | "resourcesFail" | "resourcesTotal"
>;
type FindingGroupDeltaState = Pick<
FindingGroupRow,
| "newCount"
| "changedCount"
| "newFailCount"
| "newFailMutedCount"
| "newPassCount"
| "newPassMutedCount"
| "newManualCount"
| "newManualMutedCount"
| "changedFailCount"
| "changedFailMutedCount"
| "changedPassCount"
| "changedPassMutedCount"
| "changedManualCount"
| "changedManualMutedCount"
>;
export function isFindingGroupMuted(group: FindingGroupMutedState): boolean {
if (typeof group.muted === "boolean") {
return group.muted;
}
const mutedCount = group.mutedCount ?? 0;
if (mutedCount === 0) {
return false;
}
return (
mutedCount === group.resourcesFail || mutedCount === group.resourcesTotal
);
}
function getNewDeltaTotal(group: FindingGroupDeltaState): number {
const breakdownTotal =
(group.newFailCount ?? 0) +
(group.newFailMutedCount ?? 0) +
(group.newPassCount ?? 0) +
(group.newPassMutedCount ?? 0) +
(group.newManualCount ?? 0) +
(group.newManualMutedCount ?? 0);
return breakdownTotal > 0 ? breakdownTotal : group.newCount;
}
function getChangedDeltaTotal(group: FindingGroupDeltaState): number {
const breakdownTotal =
(group.changedFailCount ?? 0) +
(group.changedFailMutedCount ?? 0) +
(group.changedPassCount ?? 0) +
(group.changedPassMutedCount ?? 0) +
(group.changedManualCount ?? 0) +
(group.changedManualMutedCount ?? 0);
return breakdownTotal > 0 ? breakdownTotal : group.changedCount;
}
export function getFindingGroupDelta(
group: FindingGroupDeltaState,
): "new" | "changed" | "none" {
if (getNewDeltaTotal(group) > 0) {
return "new";
}
if (getChangedDeltaTotal(group) > 0) {
return "changed";
}
return "none";
}
+19
View File
@@ -16,10 +16,29 @@ export interface FindingGroupRow {
checkTitle: string;
severity: Severity;
status: FindingStatus;
muted?: boolean;
resourcesTotal: number;
resourcesFail: number;
passCount?: number;
failCount?: number;
manualCount?: number;
passMutedCount?: number;
failMutedCount?: number;
manualMutedCount?: number;
newCount: number;
changedCount: number;
newFailCount?: number;
newFailMutedCount?: number;
newPassCount?: number;
newPassMutedCount?: number;
newManualCount?: number;
newManualMutedCount?: number;
changedFailCount?: number;
changedFailMutedCount?: number;
changedPassCount?: number;
changedPassMutedCount?: number;
changedManualCount?: number;
changedManualMutedCount?: number;
mutedCount: number;
providers: ProviderType[];
updatedAt: string;