From 0e8080f09cbcbf10f48179d55c98b6a5f3fd7ade Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:44:10 +0200 Subject: [PATCH] fix(ui): findings groups fixes (#10633) --- .../finding-groups.adapter.test.ts | 24 ++ .../finding-groups/finding-groups.adapter.ts | 48 +++- .../finding-groups/finding-groups.test.ts | 24 +- ui/actions/finding-groups/finding-groups.ts | 7 +- .../findings/findings-by-resource.test.ts | 258 ++++-------------- ui/actions/findings/findings-by-resource.ts | 177 ++---------- .../compliance/[compliancetitle]/page.tsx | 41 ++- ui/components/compliance/compliance-card.tsx | 12 - .../compliance/threatscore-badge.tsx | 12 - ui/components/findings/findings-filters.tsx | 14 +- .../findings/findings-filters.utils.test.ts | 18 ++ .../findings/findings-filters.utils.ts | 10 + .../findings/floating-mute-button.test.tsx | 107 +++++++- .../findings/floating-mute-button.tsx | 42 ++- .../findings/mute-findings-modal.tsx | 172 +++++++++--- .../table/column-finding-groups.test.tsx | 44 ++- .../findings/table/column-finding-groups.tsx | 24 +- .../table/column-finding-resources.test.tsx | 75 ++++- .../table/column-finding-resources.tsx | 20 +- .../findings/table/column-findings.tsx | 1 + .../table/data-table-row-actions.test.tsx | 182 ++++++++++++ .../findings/table/data-table-row-actions.tsx | 72 ++++- .../table/finding-group-selection.test.ts | 13 +- .../findings/table/finding-group-selection.ts | 20 +- .../table/findings-group-drill-down.tsx | 32 +-- .../findings/table/findings-group-table.tsx | 17 +- .../table/inline-resource-container.tsx | 102 ++++--- .../inline-resource-container.utils.test.ts | 101 +++++++ .../table/inline-resource-container.utils.ts | 55 ++++ .../findings/table/notification-indicator.tsx | 110 +++++--- .../resource-detail-drawer-content.test.tsx | 4 +- .../resource-detail-drawer-content.tsx | 24 -- .../table/resource-findings-columns.tsx | 1 + ui/lib/findings-groups.test.ts | 102 +++++++ ui/lib/findings-groups.ts | 77 ++++++ ui/types/findings-table.ts | 19 ++ 36 files changed, 1409 insertions(+), 652 deletions(-) create mode 100644 ui/components/findings/table/data-table-row-actions.test.tsx create mode 100644 ui/components/findings/table/inline-resource-container.utils.test.ts create mode 100644 ui/components/findings/table/inline-resource-container.utils.ts create mode 100644 ui/lib/findings-groups.test.ts create mode 100644 ui/lib/findings-groups.ts diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts index 1d5d04e62d..c9b4a0314c 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.test.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -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); }); }); diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index 2e3964522e..7f260c8aa6 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -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, diff --git a/ui/actions/finding-groups/finding-groups.test.ts b/ui/actions/finding-groups/finding-groups.test.ts index 9f4bdc5830..de19cb1567 100644 --- a/ui/actions/finding-groups/finding-groups.test.ts +++ b/ui/actions/finding-groups/finding-groups.test.ts @@ -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", ); diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index b08293c882..e4549d08a9 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -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); diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts index c637e5f707..4aff44a2cf 100644 --- a/ui/actions/findings/findings-by-resource.test.ts +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -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(); }); }); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 4c69d2e5ef..344c5607a0 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -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; hasDateOrScanFilter?: boolean; } -interface ResolveFindingIdsParams { - checkId: string; - resourceUids: string[]; - filters?: Record; - hasDateOrScanFilter?: boolean; -} - interface ResolveFindingIdsByVisibleGroupResourcesParams { checkId: string; filters?: Record; @@ -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(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 { - 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 { - 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 { +}): Promise { const fetchFn = hasDateOrScanFilter ? getFindingGroupResources : getLatestFindingGroupResources; + const resolvedFilters: Record = { + ...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 => { - 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 => { 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()); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index f8a1122dfd..d33168efc8 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -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 || []; diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index d700821ed6..5d1b0425ba 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -32,7 +32,6 @@ export const ComplianceCard: React.FC = ({ scanId, complianceId, id, - selectedScan, }) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -65,17 +64,6 @@ export const ComplianceCard: React.FC = ({ 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); diff --git a/ui/components/compliance/threatscore-badge.tsx b/ui/components/compliance/threatscore-badge.tsx index a047f91bdd..feb2aec91b 100644 --- a/ui/components/compliance/threatscore-badge.tsx +++ b/ui/components/compliance/threatscore-badge.tsx @@ -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); diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 526b6240f3..90c59368b0 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -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, }, ]; diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index 86a3124b0a..dcfc093cfd 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -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", { diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 6cb9c19b28..cae4763a10 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -9,6 +9,11 @@ interface GetFindingsFilterDisplayValueOptions { scans?: Array<{ [scanId: string]: ScanEntity }>; } +const FINDING_DELTA_DISPLAY_NAMES: Record = { + 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); } diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-mute-button.test.tsx index 0123e2e44e..b953b47b43 100644 --- a/ui/components/findings/floating-mute-button.test.tsx +++ b/ui/components/findings/floating-mute-button.test.tsx @@ -24,6 +24,17 @@ vi.mock("next/navigation", () => ({ import { FloatingMuteButton } from "./floating-mute-button"; +function deferredPromise() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(); + const onBeforeOpen = vi.fn().mockReturnValue(deferred.promise); + const user = userEvent.setup(); + + render( + , + ); + + // 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"], + }); + }); + }); }); diff --git a/ui/components/findings/floating-mute-button.tsx b/ui/components/findings/floating-mute-button.tsx index f3b7933ce8..b6c779370e 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-mute-button.tsx @@ -31,22 +31,46 @@ export function FloatingMuteButton({ const [isModalOpen, setIsModalOpen] = useState(false); const [resolvedIds, setResolvedIds] = useState([]); 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({ <>
diff --git a/ui/components/findings/mute-findings-modal.tsx b/ui/components/findings/mute-findings-modal.tsx index 4f4e4e2ccb..5026aa5ad5 100644 --- a/ui/components/findings/mute-findings-modal.tsx +++ b/ui/components/findings/mute-findings-modal.tsx @@ -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(null); @@ -32,6 +39,12 @@ export function MuteFindingsModal({ onOpenChange(false); }; + const isSubmitDisabled = + isPending || + isPreparing || + findingIds.length === 0 || + Boolean(preparationError); + return ( { e.preventDefault(); + if (isSubmitDisabled) { + return; + } + const formData = new FormData(e.currentTarget); startTransition(() => { @@ -77,52 +94,121 @@ export function MuteFindingsModal({ value={JSON.stringify(findingIds)} /> -
-

- You are about to mute{" "} - - {findingIds.length} - {" "} - {findingIds.length === 1 ? "finding" : "findings"}. -

-

- Muted findings will be hidden by default but can be shown using - filters. -

-
+ {isPreparing ? ( + <> +
+
+ +
+

+ Preparing findings to mute... +

+

+ Large finding groups can take a few seconds while we gather + the matching findings. +

+
+
+
- + -