diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts index 5874ddf7e5..1d5d04e62d 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.test.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -163,6 +163,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { alias: "production", }, status: "FAIL", + delta: "new", severity: "critical", first_seen_at: null, last_seen_at: "2024-01-01T00:00:00Z", @@ -178,5 +179,6 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { expect(result).toHaveLength(1); expect(result[0].checkId).toBe("s3_check"); expect(result[0].resourceName).toBe("my-bucket"); + expect(result[0].delta).toBe("new"); }); }); diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index 593171078d..2e3964522e 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -98,6 +98,7 @@ interface FindingGroupResourceAttributes { resource: ResourceInfo; provider: ProviderInfo; status: string; + delta?: string | null; severity: string; first_seen_at: string | null; last_seen_at: string | null; @@ -137,14 +138,15 @@ export function adaptFindingGroupResourcesResponse( providerAlias: item.attributes.provider?.alias || "", providerUid: item.attributes.provider?.uid || "", resourceName: item.attributes.resource?.name || "-", + resourceType: item.attributes.resource?.type || "-", resourceGroup: item.attributes.resource?.resource_group || "-", resourceUid: item.attributes.resource?.uid || "-", service: item.attributes.resource?.service || "-", region: item.attributes.resource?.region || "-", severity: (item.attributes.severity || "informational") as Severity, status: item.attributes.status, + delta: item.attributes.delta || null, isMuted: item.attributes.status === "MUTED", - // TODO: remove fallback once the API returns muted_reason in finding-group-resources 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 0d8c2df53a..9f4bdc5830 100644 --- a/ui/actions/finding-groups/finding-groups.test.ts +++ b/ui/actions/finding-groups/finding-groups.test.ts @@ -47,10 +47,6 @@ import { getLatestFindingGroupResources, } from "./finding-groups"; -// --------------------------------------------------------------------------- -// Blocker 1 + 2: FAIL-first sort and FAIL-only filter for drill-down resources -// --------------------------------------------------------------------------- - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -169,7 +165,7 @@ describe("getLatestFindingGroupResources — SSRF path traversal protection", () }); // --------------------------------------------------------------------------- -// Blocker 1: Resources list must show FAIL first (sort=-status) +// Resources list keeps FAIL-first sort but no longer forces FAIL-only filtering // --------------------------------------------------------------------------- describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => { @@ -181,30 +177,30 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => { fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should include sort=-status in the API call so FAIL resources appear first", async () => { + it("should include the composite sort so FAIL resources appear first, then severity", async () => { // Given const checkId = "s3_bucket_public_access"; // When await getFindingGroupResources({ checkId }); - // Then — the URL must contain sort=-status + // 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("-status"); + expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at"); }); - it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => { + it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => { // Given const checkId = "s3_bucket_public_access"; // When await getFindingGroupResources({ checkId }); - // Then — the URL must contain filter[status]=FAIL + // Then — the URL should not add a hardcoded status filter const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); @@ -217,7 +213,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should include sort=-status in the API call so FAIL resources appear first", async () => { + it("should include the composite sort so FAIL resources appear first, then severity", async () => { // Given const checkId = "iam_user_mfa_enabled"; @@ -227,10 +223,10 @@ 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("-status"); + expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at"); }); - it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => { + it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => { // Given const checkId = "iam_user_mfa_enabled"; @@ -240,7 +236,7 @@ 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("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); @@ -257,7 +253,7 @@ describe("getFindingGroupResources — triangulation: params coexist", () => { fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => { + it("should send the composite sort alongside pagination params without forcing filter[status]", async () => { // Given const checkId = "s3_bucket_versioning"; @@ -269,8 +265,8 @@ 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("-status"); - expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at"); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); @@ -283,7 +279,7 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", () fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => { + it("should send the composite sort alongside pagination params without forcing filter[status]", async () => { // Given const checkId = "iam_root_mfa_enabled"; @@ -295,16 +291,16 @@ 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("-status"); - expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at"); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); // --------------------------------------------------------------------------- -// Blocker: Duplicate filter[status] — caller-supplied status must be stripped +// Caller filters should propagate unchanged to the drill-down resources endpoint // --------------------------------------------------------------------------- -describe("getFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => { +describe("getFindingGroupResources — caller filters are preserved", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); @@ -313,23 +309,7 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => { - // Given — caller explicitly passes PASS, which must be ignored - const checkId = "s3_bucket_public_access"; - const filters = { "filter[status]": "PASS" }; - - // When - await getFindingGroupResources({ checkId, filters }); - - // Then — the final URL must have exactly one filter[status]=FAIL, not PASS - const calledUrl = fetchMock.mock.calls[0][0] as string; - const url = new URL(calledUrl); - const allStatusValues = url.searchParams.getAll("filter[status]"); - expect(allStatusValues).toHaveLength(1); - expect(allStatusValues[0]).toBe("FAIL"); - }); - - it("should not have duplicate filter[status] params when caller passes filter[status]", async () => { + it("should preserve caller filter[status] when explicitly provided", async () => { // Given const checkId = "s3_bucket_public_access"; const filters = { "filter[status]": "PASS" }; @@ -337,14 +317,56 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always // When await getFindingGroupResources({ checkId, filters }); - // Then — no duplicates + // Then const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.getAll("filter[status]")).toHaveLength(1); + const allStatusValues = url.searchParams.getAll("filter[status]"); + expect(allStatusValues).toHaveLength(1); + expect(allStatusValues[0]).toBe("PASS"); + }); + + it("should translate a single group status__in filter into filter[status] for resources", async () => { + // Given + const checkId = "s3_bucket_public_access"; + const filters = { + "filter[status__in]": "PASS", + "filter[severity__in]": "medium", + "filter[provider_type__in]": "aws", + }; + + // When + await getFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[status]")).toBe("PASS"); + expect(url.searchParams.get("filter[status__in]")).toBeNull(); + expect(url.searchParams.get("filter[severity__in]")).toBe("medium"); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws"); + }); + + it("should keep the composite sort when the resource search filter is applied", async () => { + // Given + const checkId = "s3_bucket_public_access"; + const filters = { + "filter[name__icontains]": "bucket-prod", + "filter[severity__in]": "high", + }; + + // When + await getFindingGroupResources({ checkId, filters }); + + // 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("filter[name__icontains]")).toBe("bucket-prod"); + expect(url.searchParams.get("filter[severity__in]")).toBe("high"); }); }); -describe("getLatestFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => { +describe("getLatestFindingGroupResources — caller filters are preserved", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); @@ -353,23 +375,7 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => { - // Given — caller explicitly passes PASS, which must be ignored - const checkId = "iam_user_mfa_enabled"; - const filters = { "filter[status]": "PASS" }; - - // When - await getLatestFindingGroupResources({ checkId, filters }); - - // Then — the final URL must have exactly one filter[status]=FAIL, not PASS - const calledUrl = fetchMock.mock.calls[0][0] as string; - const url = new URL(calledUrl); - const allStatusValues = url.searchParams.getAll("filter[status]"); - expect(allStatusValues).toHaveLength(1); - expect(allStatusValues[0]).toBe("FAIL"); - }); - - it("should not have duplicate filter[status] params when caller passes filter[status]", async () => { + it("should preserve caller filter[status] when explicitly provided", async () => { // Given const checkId = "iam_user_mfa_enabled"; const filters = { "filter[status]": "PASS" }; @@ -377,9 +383,53 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a // When await getLatestFindingGroupResources({ checkId, filters }); - // Then — no duplicates + // Then const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.getAll("filter[status]")).toHaveLength(1); + const allStatusValues = url.searchParams.getAll("filter[status]"); + expect(allStatusValues).toHaveLength(1); + expect(allStatusValues[0]).toBe("PASS"); + }); + + it("should translate a single group status__in filter into filter[status] for latest resources", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + const filters = { + "filter[status__in]": "PASS", + "filter[severity__in]": "low", + "filter[provider_type__in]": "aws", + }; + + // When + await getLatestFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[status]")).toBe("PASS"); + expect(url.searchParams.get("filter[status__in]")).toBeNull(); + expect(url.searchParams.get("filter[severity__in]")).toBe("low"); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws"); + }); + + it("should keep the composite sort when the resource search filter is applied", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + const filters = { + "filter[name__icontains]": "instance-prod", + "filter[status__in]": "PASS,FAIL", + }; + + // When + await getLatestFindingGroupResources({ checkId, filters }); + + // 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("filter[name__icontains]")).toBe( + "instance-prod", + ); + expect(url.searchParams.get("filter[status__in]")).toBe("PASS,FAIL"); }); }); diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index b31d7a5bfc..b08293c882 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -23,17 +23,68 @@ function mapSearchFilter( return mapped; } -export const getFindingGroups = async ({ - page = 1, - pageSize = 10, - sort = "", - filters = {}, -}) => { +function splitCsvFilterValues(value: string | string[] | undefined): string[] { + if (Array.isArray(value)) { + return value + .flatMap((item) => item.split(",")) + .map((item) => item.trim()) + .filter(Boolean); + } + + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + + return []; +} + +function normalizeFindingGroupResourceFilters( + filters: Record, +): Record { + const normalized = { ...filters }; + const exactStatusFilter = normalized["filter[status]"]; + + if (exactStatusFilter !== undefined) { + delete normalized["filter[status__in]"]; + return normalized; + } + + const statusValues = splitCsvFilterValues(normalized["filter[status__in]"]); + if (statusValues.length === 1) { + normalized["filter[status]"] = statusValues[0]; + delete normalized["filter[status__in]"]; + } + + return normalized; +} + +const DEFAULT_FINDING_GROUPS_SORT = + "-severity,-delta,-fail_count,-last_seen_at"; + +interface FetchFindingGroupsParams { + page?: number; + pageSize?: number; + sort?: string; + filters?: Record; +} + +async function fetchFindingGroupsEndpoint( + endpoint: string, + { + page = 1, + pageSize = 10, + sort = DEFAULT_FINDING_GROUPS_SORT, + filters = {}, + }: FetchFindingGroupsParams, +) { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/findings"); - const url = new URL(`${apiBaseUrl}/finding-groups`); + const url = new URL(`${apiBaseUrl}/${endpoint}`); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); @@ -45,120 +96,60 @@ export const getFindingGroups = async ({ const response = await fetch(url.toString(), { headers }); return handleApiResponse(response); } catch (error) { - console.error("Error fetching finding groups:", error); + console.error(`Error fetching ${endpoint}:`, error); return undefined; } -}; +} -export const getLatestFindingGroups = async ({ - page = 1, - pageSize = 10, - sort = "", - filters = {}, -}) => { +export const getFindingGroups = async (params: FetchFindingGroupsParams = {}) => + fetchFindingGroupsEndpoint("finding-groups", params); + +export const getLatestFindingGroups = async ( + params: FetchFindingGroupsParams = {}, +) => fetchFindingGroupsEndpoint("finding-groups/latest", params); + +interface FetchFindingGroupResourcesParams { + checkId: string; + page?: number; + pageSize?: number; + filters?: Record; +} + +async function fetchFindingGroupResourcesEndpoint( + endpointPrefix: string, + { + checkId, + page = 1, + pageSize = 20, + filters = {}, + }: FetchFindingGroupResourcesParams, +) { const headers = await getAuthHeaders({ contentType: false }); + const normalizedFilters = normalizeFindingGroupResourceFilters(filters); - if (isNaN(Number(page)) || page < 1) redirect("/findings"); - - const url = new URL(`${apiBaseUrl}/finding-groups/latest`); + const url = new URL( + `${apiBaseUrl}/${endpointPrefix}/${encodeURIComponent(checkId)}/resources`, + ); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); - if (sort) url.searchParams.append("sort", sort); + url.searchParams.append("sort", "-severity,-delta,-last_seen_at"); - appendSanitizedProviderFilters(url, mapSearchFilter(filters)); + appendSanitizedProviderFilters(url, normalizedFilters); try { const response = await fetch(url.toString(), { headers }); return handleApiResponse(response); } catch (error) { - console.error("Error fetching latest finding groups:", error); + console.error(`Error fetching ${endpointPrefix} resources:`, error); return undefined; } -}; +} -export const getFindingGroupResources = async ({ - checkId, - page = 1, - pageSize = 20, - filters = {}, -}: { - checkId: string; - page?: number; - pageSize?: number; - filters?: Record; -}) => { - const headers = await getAuthHeaders({ contentType: false }); +export const getFindingGroupResources = async ( + params: FetchFindingGroupResourcesParams, +) => fetchFindingGroupResourcesEndpoint("finding-groups", params); - const url = new URL( - `${apiBaseUrl}/finding-groups/${encodeURIComponent(checkId)}/resources`, - ); - - if (page) url.searchParams.append("page[number]", page.toString()); - if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); - // sort=-status is kept for future-proofing: if the filter[status]=FAIL - // constraint is ever relaxed to allow multiple statuses, the sort ensures - // FAIL resources still appear first in the result set. - url.searchParams.append("sort", "-status"); - - appendSanitizedProviderFilters(url, filters); - - // Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL - // always wins, even if the caller passed a different filter[status] value. - // Using .set() instead of .append() prevents duplicate filter[status] params. - url.searchParams.set("filter[status]", "FAIL"); - - try { - const response = await fetch(url.toString(), { - headers, - }); - - return handleApiResponse(response); - } catch (error) { - console.error("Error fetching finding group resources:", error); - return undefined; - } -}; - -export const getLatestFindingGroupResources = async ({ - checkId, - page = 1, - pageSize = 20, - filters = {}, -}: { - checkId: string; - page?: number; - pageSize?: number; - filters?: Record; -}) => { - const headers = await getAuthHeaders({ contentType: false }); - - const url = new URL( - `${apiBaseUrl}/finding-groups/latest/${encodeURIComponent(checkId)}/resources`, - ); - - if (page) url.searchParams.append("page[number]", page.toString()); - if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); - // sort=-status is kept for future-proofing: if the filter[status]=FAIL - // constraint is ever relaxed to allow multiple statuses, the sort ensures - // FAIL resources still appear first in the result set. - url.searchParams.append("sort", "-status"); - - appendSanitizedProviderFilters(url, filters); - - // Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL - // always wins, even if the caller passed a different filter[status] value. - // Using .set() instead of .append() prevents duplicate filter[status] params. - url.searchParams.set("filter[status]", "FAIL"); - - try { - const response = await fetch(url.toString(), { - headers, - }); - - return handleApiResponse(response); - } catch (error) { - console.error("Error fetching latest finding group resources:", error); - return undefined; - } -}; +export const getLatestFindingGroupResources = async ( + params: FetchFindingGroupResourcesParams, +) => fetchFindingGroupResourcesEndpoint("finding-groups/latest", params); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 12900ffdca..4c69d2e5ef 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -379,6 +379,9 @@ 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"); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts new file mode 100644 index 0000000000..76462dff99 --- /dev/null +++ b/ui/app/(prowler)/findings/page.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +/** + * Source-level assertions for the findings page. + * + * Directly importing page.tsx triggers deep transitive imports + * (next-auth → next/server) that vitest cannot resolve without the + * full Next.js build pipeline. These tests verify key architectural + * invariants via source analysis instead. + */ +describe("findings page", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + it("only passes sort to fetchFindingGroups when the user has an explicit sort param", () => { + expect(source).toContain("...(encodedSort && { sort: encodedSort })"); + }); + + it("normalizes scan filters with the required inserted_at params before fetching historical finding groups", () => { + expect(source).toContain("resolveFindingScanDateFilters"); + }); + + it("uses getLatestFindingGroups for non-date/scan queries and getFindingGroups for historical", () => { + expect(source).toContain("hasDateOrScan"); + expect(source).toContain("getFindingGroups"); + expect(source).toContain("getLatestFindingGroups"); + }); + + it("guards errors array access with a length check", () => { + expect(source).toContain("errors?.length > 0"); + }); +}); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 576d34ca1e..46749b38c2 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -7,7 +7,7 @@ import { } from "@/actions/finding-groups"; import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; import { getProviders } from "@/actions/providers"; -import { getScans } from "@/actions/scans"; +import { getScan, getScans } from "@/actions/scans"; import { FindingsFilters } from "@/components/findings/findings-filters"; import { FindingsGroupTable, @@ -21,6 +21,7 @@ import { extractSortAndKey, hasDateOrScanFilter, } from "@/lib"; +import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters"; import { ScanEntity, ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; @@ -39,16 +40,28 @@ export default async function Findings({ // TODO: Re-implement deep link support (/findings?id=) using the grouped view's resource detail drawer // once the legacy FindingDetailsSheet is fully deprecated (still used by /resources and overview dashboard). - const [metadataInfoData, providersData, scansData] = await Promise.all([ - (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ - query, - sort: encodedSort, - filters, - }), + const [providersData, scansData] = await Promise.all([ getProviders({ pageSize: 50 }), getScans({ pageSize: 50 }), ]); + const filtersWithScanDates = await resolveFindingScanDateFilters({ + filters, + scans: scansData?.data || [], + loadScan: async (scanId: string) => { + const response = await getScan(scanId); + return response?.data; + }, + }); + + const metadataInfoData = await ( + hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo + )({ + query, + sort: encodedSort, + filters: filtersWithScanDates, + }); + // Extract unique regions, services, categories, groups from the new endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; @@ -88,7 +101,10 @@ export default async function Findings({ /> }> - + @@ -97,19 +113,15 @@ export default async function Findings({ const SSRDataTable = async ({ searchParams, + filters, }: { searchParams: SearchParamsProps; + filters: Record; }) => { const page = parseInt(searchParams.page?.toString() || "1", 10); const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); - const defaultSort = "-severity,-fail_count,-last_seen_at"; - const { encodedSort } = extractSortAndKey({ - ...searchParams, - sort: searchParams.sort ?? defaultSort, - }); - - const { filters } = extractFiltersAndQuery(searchParams); + const { encodedSort } = extractSortAndKey(searchParams); // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(searchParams); @@ -119,7 +131,7 @@ const SSRDataTable = async ({ const findingGroupsData = await fetchFindingGroups({ page, - sort: encodedSort, + ...(encodedSort && { sort: encodedSort }), filters, pageSize, }); @@ -131,7 +143,7 @@ const SSRDataTable = async ({ return ( <> - {findingGroupsData?.errors && ( + {findingGroupsData?.errors?.length > 0 && (

Error:

{findingGroupsData.errors[0].detail}

diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 2d0416f37c..55dbf7eb8a 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -18,7 +18,12 @@ import { createProviderDetailsMapping, extractProviderUIDs, } from "@/lib/provider-helpers"; -import { ProviderProps, ScanProps, SearchParamsProps } from "@/types"; +import { + ExpandedScanData, + ProviderProps, + ScanProps, + SearchParamsProps, +} from "@/types"; export default async function Scans({ searchParams, @@ -30,7 +35,34 @@ export default async function Scans({ const filteredParams = { ...resolvedSearchParams }; delete filteredParams.scanId; - const providersData = await getAllProviders(); + const [providersData, completedScansData] = await Promise.all([ + getAllProviders(), + getScans({ + filters: { "filter[state]": "completed" }, + pageSize: 50, + fields: { scans: "name,completed_at,provider" }, + include: "provider", + }), + ]); + + const completedScans: ExpandedScanData[] = (completedScansData?.data ?? []) + .map((scan: ScanProps) => { + const providerId = scan.relationships?.provider?.data?.id; + const providerData = completedScansData?.included?.find( + (item: { type: string; id: string }) => + item.type === "providers" && item.id === providerId, + ); + if (!providerData) return null; + return { + ...scan, + providerInfo: { + provider: providerData.attributes.provider, + uid: providerData.attributes.uid, + alias: providerData.attributes.alias, + }, + }; + }) + .filter(Boolean) as ExpandedScanData[]; const providerInfo = providersData?.data @@ -90,6 +122,7 @@ export default async function Scans({
diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index af169bfcc1..526b6240f3 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -18,11 +18,12 @@ import { Button } from "@/components/shadcn"; import { ExpandableSection } from "@/components/ui/expandable-section"; import { DataTableFilterCustom } from "@/components/ui/table"; import { useFilterBatch } from "@/hooks/use-filter-batch"; -import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories"; -import { FilterType, FINDING_STATUS_DISPLAY_NAMES, ScanEntity } from "@/types"; +import { getCategoryLabel, getGroupLabel } from "@/lib/categories"; +import { FilterType, ScanEntity } from "@/types"; import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters"; -import { getProviderDisplayName, ProviderProps } from "@/types/providers"; -import { SEVERITY_DISPLAY_NAMES } from "@/types/severities"; +import { ProviderProps } from "@/types/providers"; + +import { getFindingsFilterDisplayValue } from "./findings-filters.utils"; interface FindingsFiltersProps { /** Provider data for ProviderTypeSelector and AccountsSelector */ @@ -58,49 +59,6 @@ const FILTER_KEY_LABELS: Record = { "filter[muted]": "Muted", }; -/** - * Formats a raw filter value into a human-readable display string. - * - Provider types: uses shared getProviderDisplayName utility - * - Severities: uses shared SEVERITY_DISPLAY_NAMES (e.g. "critical" → "Critical") - * - Status: uses shared FINDING_STATUS_DISPLAY_NAMES (e.g. "FAIL" → "Fail") - * - Categories: uses getCategoryLabel (handles IAM, EC2, IMDSv1, etc.) - * - Resource groups: uses getGroupLabel (underscore-delimited) - * - Date (filter[inserted_at]): returns the ISO date string as-is (YYYY-MM-DD) - * - Other values: uses formatLabel as a generic fallback (avoids naive capitalisation) - */ -const formatFilterValue = (filterKey: string, value: string): string => { - if (!value) return value; - if (filterKey === "filter[provider_type__in]") { - return getProviderDisplayName(value); - } - if (filterKey === "filter[severity__in]") { - return ( - SEVERITY_DISPLAY_NAMES[ - value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES - ] ?? formatLabel(value) - ); - } - if (filterKey === "filter[status__in]") { - return ( - FINDING_STATUS_DISPLAY_NAMES[ - value as keyof typeof FINDING_STATUS_DISPLAY_NAMES - ] ?? formatLabel(value) - ); - } - if (filterKey === "filter[category__in]") { - return getCategoryLabel(value); - } - if (filterKey === "filter[resource_groups__in]") { - return getGroupLabel(value); - } - // Date filter: preserve ISO date string (YYYY-MM-DD) — do not run through formatLabel - if (filterKey === "filter[inserted_at]") { - return value; - } - // Generic fallback: handles hyphen/underscore-delimited IDs with smart capitalisation - return formatLabel(value); -}; - export const FindingsFilters = ({ providers, completedScanIds, @@ -185,7 +143,10 @@ export const FindingsFilters = ({ key, label, value, - displayValue: formatFilterValue(key, value), + displayValue: getFindingsFilterDisplayValue(key, value, { + providers, + scans: scanDetails, + }), }); }); }); diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts new file mode 100644 index 0000000000..86a3124b0a --- /dev/null +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { ProviderProps } from "@/types/providers"; +import { ScanEntity } from "@/types/scans"; + +import { getFindingsFilterDisplayValue } from "./findings-filters.utils"; + +function makeProvider( + overrides: Partial & { id: string }, +): ProviderProps { + return { + type: "providers", + attributes: { + provider: "aws", + uid: "123456789012", + alias: "Production Account", + status: "completed", + resources: 10, + connection: { connected: true, last_checked_at: "2026-04-07T10:00:00Z" }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-07T10:00:00Z", + updated_at: "2026-04-07T10:00:00Z", + created_by: { object: "user", id: "user-1" }, + }, + relationships: { + secret: { data: null }, + provider_groups: { meta: { count: 0 }, data: [] }, + }, + ...overrides, + } as ProviderProps; +} + +function makeScanMap( + scanId: string, + overrides?: Partial, +): { [scanId: string]: ScanEntity } { + return { + [scanId]: { + id: scanId, + providerInfo: { + provider: "aws", + alias: "Scan Account", + uid: "123456789012", + }, + attributes: { + name: "Nightly scan", + completed_at: "2026-04-07T10:00:00Z", + }, + ...overrides, + }, + }; +} + +const providers = [makeProvider({ id: "provider-1" })]; +const scans = [makeScanMap("scan-1")]; + +describe("getFindingsFilterDisplayValue", () => { + it("shows the account alias for provider_id filters instead of the raw provider id", () => { + expect( + getFindingsFilterDisplayValue("filter[provider_id__in]", "provider-1", { + providers, + }), + ).toBe("Production Account"); + }); + + it("falls back to the provider uid when the alias is empty", () => { + expect( + getFindingsFilterDisplayValue("filter[provider_id__in]", "provider-2", { + providers: [ + ...providers, + makeProvider({ + id: "provider-2", + attributes: { + ...providers[0].attributes, + alias: "", + uid: "210987654321", + }, + }), + ], + }), + ).toBe("210987654321"); + }); + + it("keeps the raw value when the provider cannot be resolved", () => { + expect( + getFindingsFilterDisplayValue( + "filter[provider_id__in]", + "missing-provider", + { providers }, + ), + ).toBe("missing-provider"); + }); + + it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => { + expect( + getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }), + ).toBe("Scan Account"); + }); + + it("falls back to the scan provider uid when the alias is missing", () => { + expect( + getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", { + scans: [ + ...scans, + makeScanMap("scan-2", { + providerInfo: { provider: "aws", uid: "210987654321" }, + attributes: { + name: "Weekly scan", + completed_at: "2026-04-08T10:00:00Z", + }, + }), + ], + }), + ).toBe("210987654321"); + }); + + it("keeps the raw scan value when the scan cannot be resolved", () => { + expect( + getFindingsFilterDisplayValue("filter[scan__in]", "missing-scan", { + scans, + }), + ).toBe("missing-scan"); + }); + + it("passes through date values for inserted_at__gte filters", () => { + expect( + getFindingsFilterDisplayValue( + "filter[inserted_at__gte]", + "2026-04-03", + {}, + ), + ).toBe("2026-04-03"); + }); + + it("passes through date values for inserted_at__lte filters", () => { + expect( + getFindingsFilterDisplayValue( + "filter[inserted_at__lte]", + "2026-04-07", + {}, + ), + ).toBe("2026-04-07"); + }); +}); diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts new file mode 100644 index 0000000000..6cb9c19b28 --- /dev/null +++ b/ui/components/findings/findings-filters.utils.ts @@ -0,0 +1,80 @@ +import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories"; +import { FINDING_STATUS_DISPLAY_NAMES } from "@/types"; +import { getProviderDisplayName, ProviderProps } from "@/types/providers"; +import { ScanEntity } from "@/types/scans"; +import { SEVERITY_DISPLAY_NAMES } from "@/types/severities"; + +interface GetFindingsFilterDisplayValueOptions { + providers?: ProviderProps[]; + scans?: Array<{ [scanId: string]: ScanEntity }>; +} + +function getProviderAccountDisplayValue( + providerId: string, + providers: ProviderProps[], +): string { + const provider = providers.find((item) => item.id === providerId); + if (!provider) { + return providerId; + } + + return provider.attributes.alias || provider.attributes.uid || providerId; +} + +function getScanDisplayValue( + scanId: string, + scans: Array<{ [scanId: string]: ScanEntity }>, +): string { + const scan = scans.find((item) => item[scanId])?.[scanId]; + if (!scan) { + return scanId; + } + + return scan.providerInfo.alias || scan.providerInfo.uid || scanId; +} + +export function getFindingsFilterDisplayValue( + filterKey: string, + value: string, + options: GetFindingsFilterDisplayValueOptions = {}, +): string { + if (!value) return value; + if (filterKey === "filter[provider_type__in]") { + return getProviderDisplayName(value); + } + if (filterKey === "filter[provider_id__in]") { + return getProviderAccountDisplayValue(value, options.providers || []); + } + if (filterKey === "filter[scan__in]") { + return getScanDisplayValue(value, options.scans || []); + } + if (filterKey === "filter[severity__in]") { + return ( + SEVERITY_DISPLAY_NAMES[ + value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES + ] ?? formatLabel(value) + ); + } + if (filterKey === "filter[status__in]") { + return ( + FINDING_STATUS_DISPLAY_NAMES[ + value as keyof typeof FINDING_STATUS_DISPLAY_NAMES + ] ?? formatLabel(value) + ); + } + if (filterKey === "filter[category__in]") { + return getCategoryLabel(value); + } + if (filterKey === "filter[resource_groups__in]") { + return getGroupLabel(value); + } + if ( + filterKey === "filter[inserted_at]" || + filterKey === "filter[inserted_at__gte]" || + filterKey === "filter[inserted_at__lte]" + ) { + return value; + } + + return formatLabel(value); +} diff --git a/ui/components/findings/table/column-finding-groups.test.tsx b/ui/components/findings/table/column-finding-groups.test.tsx index ab5d26bc62..e723e51862 100644 --- a/ui/components/findings/table/column-finding-groups.test.tsx +++ b/ui/components/findings/table/column-finding-groups.test.tsx @@ -17,11 +17,20 @@ vi.mock("next/navigation", () => ({ vi.mock("@/components/shadcn", () => ({ Checkbox: ({ "aria-label": ariaLabel, + onCheckedChange, ...props }: InputHTMLAttributes & { "aria-label"?: string; size?: string; - }) => , + onCheckedChange?: (checked: boolean) => void; + }) => ( + onCheckedChange?.(event.target.checked)} + {...props} + /> + ), })); vi.mock("@/components/ui/table", () => ({ @@ -52,7 +61,13 @@ vi.mock("./impacted-providers-cell", () => ({ })); vi.mock("./impacted-resources-cell", () => ({ - ImpactedResourcesCell: () => null, + ImpactedResourcesCell: ({ + impacted, + total, + }: { + impacted: number; + total: number; + }) => {`${impacted}/${total}`}, })); vi.mock("./notification-indicator", () => ({ @@ -94,6 +109,7 @@ function makeGroup(overrides?: Partial): FindingGroupRow { function renderFindingCell( checkTitle: string, onDrillDown: (checkId: string, group: FindingGroupRow) => void, + overrides?: Partial, ) { const columns = getColumnFindingGroups({ rowSelection: {}, @@ -107,7 +123,7 @@ function renderFindingCell( ); if (!findingColumn?.cell) throw new Error("finding column not found"); - const group = makeGroup({ checkTitle }); + const group = makeGroup({ checkTitle, ...overrides }); // Render the cell directly with a minimal row mock const CellComponent = findingColumn.cell as (props: { row: { original: FindingGroupRow }; @@ -116,6 +132,67 @@ function renderFindingCell( render(
{CellComponent({ row: { original: group } })}
); } +function renderImpactedResourcesCell(overrides?: Partial) { + const columns = getColumnFindingGroups({ + rowSelection: {}, + selectableRowCount: 1, + onDrillDown: vi.fn(), + }); + + const impactedResourcesColumn = columns.find( + (col) => (col as { id?: string }).id === "impactedResources", + ); + if (!impactedResourcesColumn?.cell) { + throw new Error("impactedResources column not found"); + } + + const group = makeGroup(overrides); + const CellComponent = impactedResourcesColumn.cell as (props: { + row: { original: FindingGroupRow }; + }) => ReactNode; + + render(
{CellComponent({ row: { original: group } })}
); +} + +function renderSelectCell(overrides?: Partial) { + const toggleSelected = vi.fn(); + const columns = getColumnFindingGroups({ + rowSelection: {}, + selectableRowCount: 1, + onDrillDown: vi.fn(), + }); + + const selectColumn = columns.find( + (col) => (col as { id?: string }).id === "select", + ); + if (!selectColumn?.cell) { + throw new Error("select column not found"); + } + + const group = makeGroup(overrides); + const CellComponent = selectColumn.cell as (props: { + row: { + id: string; + original: FindingGroupRow; + toggleSelected: (selected: boolean) => void; + }; + }) => ReactNode; + + render( +
+ {CellComponent({ + row: { + id: "0", + original: group, + toggleSelected, + }, + })} +
, + ); + + return { toggleSelected }; +} + // --------------------------------------------------------------------------- // Fix 5: Accessibility —

→ + ), +})); + +vi.mock("@/components/shadcn/info-field/info-field", () => ({ + InfoField: () => null, +})); + +vi.mock("@/components/shadcn/spinner/spinner", () => ({ + Spinner: () => null, +})); + +vi.mock("@/components/ui/entities", () => ({ + DateWithTime: () => null, +})); + +vi.mock("@/components/ui/entities/entity-info", () => ({ + EntityInfo: ({ + entityAlias, + entityId, + }: { + entityAlias?: string; + entityId?: string; + }) => ( +

+ {entityAlias} + {entityId} +
+ ), +})); + +vi.mock("@/components/ui/table", () => ({ + SeverityBadge: ({ severity }: { severity: string }) => ( + {severity} + ), +})); + +vi.mock("@/components/ui/table/data-table-column-header", () => ({ + DataTableColumnHeader: ({ title }: { title: string }) => {title}, +})); + +vi.mock("@/components/ui/table/status-finding-badge", () => ({ + StatusFindingBadge: ({ status }: { status: string }) => {status}, +})); + +vi.mock("@/lib/date-utils", () => ({ + getFailingForLabel: () => "2d", +})); + +const notificationIndicatorMock = vi.fn((_props: unknown) => null); + +vi.mock("./notification-indicator", () => ({ + NotificationIndicator: (props: unknown) => { + notificationIndicatorMock(props); + return null; + }, +})); + +import type { FindingResourceRow } from "@/types"; + +import { getColumnFindingResources } from "./column-finding-resources"; + +function makeResource( + overrides?: Partial, +): FindingResourceRow { + return { + id: "resource-row-1", + rowType: "resource", + findingId: "finding-1", + checkId: "s3_check", + providerType: "aws", + providerAlias: "production", + providerUid: "123456789", + resourceName: "my-bucket", + resourceType: "bucket", + resourceGroup: "default", + resourceUid: "arn:aws:s3:::my-bucket", + service: "s3", + region: "us-east-1", + severity: "critical", + status: "FAIL", + delta: "new", + isMuted: false, + firstSeenAt: null, + lastSeenAt: "2024-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("column-finding-resources", () => { + it("should pass delta to NotificationIndicator for resource rows", () => { + const columns = getColumnFindingResources({ + rowSelection: {}, + selectableRowCount: 1, + }); + + const selectColumn = columns.find( + (col) => (col as { id?: string }).id === "select", + ); + if (!selectColumn?.cell) { + throw new Error("select column not found"); + } + + const CellComponent = selectColumn.cell as (props: { + row: { + id: string; + original: FindingResourceRow; + toggleSelected: (selected: boolean) => void; + }; + }) => ReactNode; + + render( +
+ {CellComponent({ + row: { + id: "0", + original: makeResource(), + toggleSelected: vi.fn(), + }, + })} +
, + ); + + expect(screen.getByLabelText("Select resource")).toBeInTheDocument(); + expect(notificationIndicatorMock).toHaveBeenCalledWith( + expect.objectContaining({ + delta: "new", + isMuted: false, + }), + ); + }); + + it("should render the resource EntityInfo with resourceName as alias", () => { + const columns = getColumnFindingResources({ + rowSelection: {}, + selectableRowCount: 1, + }); + + const resourceColumn = columns.find( + (col) => (col as { id?: string }).id === "resource", + ); + if (!resourceColumn?.cell) { + throw new Error("resource column not found"); + } + + const CellComponent = resourceColumn.cell as (props: { + row: { original: FindingResourceRow }; + }) => ReactNode; + + render( +
+ {CellComponent({ + row: { + original: makeResource(), + }, + })} +
, + ); + + expect(screen.getByText("my-bucket")).toBeInTheDocument(); + expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index 98541a8316..50fbb52f20 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -25,11 +25,16 @@ import { import { getFailingForLabel } from "@/lib/date-utils"; import { FindingResourceRow } from "@/types"; +import { canMuteFindingResource } from "./finding-resource-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; -import { NotificationIndicator } from "./notification-indicator"; +import { + type DeltaType, + NotificationIndicator, +} from "./notification-indicator"; const ResourceRowActions = ({ row }: { row: Row }) => { const resource = row.original; + const canMute = canMuteFindingResource(resource); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const [resolvedIds, setResolvedIds] = useState([]); @@ -81,7 +86,7 @@ const ResourceRowActions = ({ row }: { row: Row }) => { return ( <> - {!resource.isMuted && ( + {canMute && ( }) => { ) } label={isResolving ? "Resolving..." : getMuteLabel()} - disabled={resource.isMuted || isResolving} + disabled={!canMute || isResolving} onSelect={handleMuteClick} /> (
@@ -178,7 +184,7 @@ export function getColumnFindingResources({ row.toggleSelected(checked === true)} onClick={(e) => e.stopPropagation()} aria-label="Select resource" @@ -198,7 +204,7 @@ export function getColumnFindingResources({
} - entityAlias={row.original.resourceGroup} + entityAlias={row.original.resourceName} entityId={row.original.resourceUid} />
@@ -213,8 +219,12 @@ export function getColumnFindingResources({ ), cell: ({ row }) => { const rawStatus = row.original.status; - const status = - rawStatus === "MUTED" ? "FAIL" : (rawStatus as FindingStatus); + const status: FindingStatus = + rawStatus === "MUTED" || rawStatus === "FAIL" + ? "FAIL" + : rawStatus === "PASS" + ? "PASS" + : "FAIL"; return ; }, enableSorting: false, diff --git a/ui/components/findings/table/finding-group-selection.test.ts b/ui/components/findings/table/finding-group-selection.test.ts new file mode 100644 index 0000000000..f00d17a73e --- /dev/null +++ b/ui/components/findings/table/finding-group-selection.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { canMuteFindingGroup } from "./finding-group-selection"; + +describe("canMuteFindingGroup", () => { + it("returns false when impacted resources is zero", () => { + expect( + canMuteFindingGroup({ + resourcesFail: 0, + resourcesTotal: 2, + mutedCount: 0, + }), + ).toBe(false); + }); + + it("returns false when all resources are already muted", () => { + expect( + canMuteFindingGroup({ + resourcesFail: 3, + resourcesTotal: 3, + mutedCount: 3, + }), + ).toBe(false); + }); + + it("returns false when all failing resources are muted even if PASS resources exist", () => { + expect( + canMuteFindingGroup({ + resourcesFail: 2, + resourcesTotal: 5, + mutedCount: 2, + }), + ).toBe(false); + }); + + it("returns true when the group still has failing resources to mute", () => { + expect( + canMuteFindingGroup({ + resourcesFail: 2, + resourcesTotal: 5, + mutedCount: 1, + }), + ).toBe(true); + }); +}); diff --git a/ui/components/findings/table/finding-group-selection.ts b/ui/components/findings/table/finding-group-selection.ts new file mode 100644 index 0000000000..f9db1cf11e --- /dev/null +++ b/ui/components/findings/table/finding-group-selection.ts @@ -0,0 +1,13 @@ +interface FindingGroupSelectionState { + resourcesFail: number; + resourcesTotal: number; + mutedCount: number; +} + +export function canMuteFindingGroup({ + resourcesFail, + mutedCount, +}: FindingGroupSelectionState): boolean { + const allMuted = mutedCount > 0 && mutedCount === resourcesFail; + return resourcesFail > 0 && !allMuted; +} diff --git a/ui/components/findings/table/finding-resource-selection.test.ts b/ui/components/findings/table/finding-resource-selection.test.ts new file mode 100644 index 0000000000..8abb3f7a8a --- /dev/null +++ b/ui/components/findings/table/finding-resource-selection.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import type { FindingResourceRow } from "@/types"; + +import { canMuteFindingResource } from "./finding-resource-selection"; + +function makeResource( + overrides?: Partial, +): FindingResourceRow { + return { + id: "finding-1", + rowType: "resource", + findingId: "finding-1", + checkId: "check-1", + providerType: "aws", + providerAlias: "prod", + providerUid: "123456789012", + resourceName: "bucket-a", + resourceType: "Bucket", + resourceGroup: "bucket-a", + resourceUid: "arn:aws:s3:::bucket-a", + service: "s3", + region: "us-east-1", + severity: "high", + status: "FAIL", + isMuted: false, + firstSeenAt: null, + lastSeenAt: null, + ...overrides, + }; +} + +describe("canMuteFindingResource", () => { + it("should allow muting FAIL resources that are not muted", () => { + expect(canMuteFindingResource(makeResource())).toBe(true); + }); + + it("should disable muting for PASS resources", () => { + expect(canMuteFindingResource(makeResource({ status: "PASS" }))).toBe( + false, + ); + }); + + it("should disable muting for already muted resources", () => { + expect(canMuteFindingResource(makeResource({ isMuted: true }))).toBe(false); + }); +}); diff --git a/ui/components/findings/table/finding-resource-selection.ts b/ui/components/findings/table/finding-resource-selection.ts new file mode 100644 index 0000000000..f6bfb3312a --- /dev/null +++ b/ui/components/findings/table/finding-resource-selection.ts @@ -0,0 +1,5 @@ +import { FindingResourceRow } from "@/types"; + +export function canMuteFindingResource(resource: FindingResourceRow): boolean { + return resource.status === "FAIL" && !resource.isMuted; +} diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 3046c09a4a..53a568e759 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -28,6 +28,7 @@ import { FindingGroupRow, FindingResourceRow } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; import { getColumnFindingResources } from "./column-finding-resources"; +import { canMuteFindingResource } from "./finding-resource-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; import { DeltaValues, NotificationIndicator } from "./notification-indicator"; @@ -82,7 +83,7 @@ export function FindingsGroupDrillDown({ setIsLoading(loading); }; - const { sentinelRef, refresh, loadMore } = useInfiniteResources({ + const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({ checkId: group.checkId, hasDateOrScanFilter: hasDateOrScan, filters, @@ -95,7 +96,7 @@ export function FindingsGroupDrillDown({ const drawer = useResourceDetailDrawer({ resources, checkId: group.checkId, - totalResourceCount: group.resourcesTotal, + totalResourceCount: totalCount ?? group.resourcesTotal, onRequestMoreResources: loadMore, }); @@ -108,7 +109,7 @@ export function FindingsGroupDrillDown({ const selectedFindingIds = Object.keys(rowSelection) .filter((key) => rowSelection[key]) .map((idx) => resources[parseInt(idx)]?.findingId) - .filter(Boolean); + .filter((id): id is string => id !== null && id !== undefined && id !== ""); /** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */ const resolveResourceIds = async (ids: string[]) => { @@ -124,10 +125,10 @@ export function FindingsGroupDrillDown({ }); }; - const selectableRowCount = resources.filter((r) => !r.isMuted).length; + const selectableRowCount = resources.filter(canMuteFindingResource).length; const getRowCanSelect = (row: Row): boolean => { - return !row.original.isMuted; + return canMuteFindingResource(row.original); }; const clearSelection = () => { diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index bcbc8934b0..3d1fd24882 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -14,6 +14,7 @@ import { FindingGroupRow, MetaDataProps } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; import { getColumnFindingGroups } from "./column-finding-groups"; +import { canMuteFindingGroup } from "./finding-group-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; import { InlineResourceContainer, @@ -88,13 +89,21 @@ export function FindingsGroupTable({ .filter(Boolean); // Count of selectable rows (groups where not ALL findings are muted) - const selectableRowCount = safeData.filter( - (g) => !(g.mutedCount > 0 && g.mutedCount === g.resourcesTotal), + const selectableRowCount = safeData.filter((g) => + canMuteFindingGroup({ + resourcesFail: g.resourcesFail, + resourcesTotal: g.resourcesTotal, + mutedCount: g.mutedCount, + }), ).length; const getRowCanSelect = (row: Row): boolean => { const group = row.original; - return !(group.mutedCount > 0 && group.mutedCount === group.resourcesTotal); + return canMuteFindingGroup({ + resourcesFail: group.resourcesFail, + resourcesTotal: group.resourcesTotal, + mutedCount: group.mutedCount, + }); }; const clearSelection = () => { @@ -136,8 +145,8 @@ export function FindingsGroupTable({ }; const handleDrillDown = (checkId: string, group: FindingGroupRow) => { - // No impacted resources → nothing to show, skip drill-down - if (group.resourcesFail === 0) return; + // No resources in the group → nothing to show, skip drill-down + if (group.resourcesTotal === 0) return; // Toggle: same group = collapse, different = switch if (expandedCheckId === checkId) { diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index e44c3db2de..9b85b58406 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -22,6 +22,7 @@ import { hasDateOrScanFilter } from "@/lib"; import { FindingGroupRow, FindingResourceRow } from "@/types"; import { getColumnFindingResources } from "./column-finding-resources"; +import { canMuteFindingResource } from "./finding-resource-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ResourceDetailDrawer, @@ -180,7 +181,7 @@ export function InlineResourceContainer({ setIsLoading(loading); }; - const { sentinelRef, refresh, loadMore } = useInfiniteResources({ + const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({ checkId: group.checkId, hasDateOrScanFilter: hasDateOrScan, filters, @@ -194,7 +195,7 @@ export function InlineResourceContainer({ const drawer = useResourceDetailDrawer({ resources, checkId: group.checkId, - totalResourceCount: group.resourcesTotal, + totalResourceCount: totalCount ?? group.resourcesTotal, onRequestMoreResources: loadMore, }); @@ -222,10 +223,10 @@ export function InlineResourceContainer({ }); }; - const selectableRowCount = resources.filter((r) => !r.isMuted).length; + const selectableRowCount = resources.filter(canMuteFindingResource).length; const getRowCanSelect = (row: Row): boolean => { - return !row.original.isMuted; + return canMuteFindingResource(row.original); }; const clearSelection = () => { diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index d3e588dd9e..3835242e19 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -1,6 +1,7 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; +import { createPortal } from "react-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- @@ -10,17 +11,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const { mockGetComplianceIcon, mockGetCompliancesOverview, - mockRouterPush, + mockWindowOpen, mockSearchParamsState, } = vi.hoisted(() => ({ mockGetComplianceIcon: vi.fn((_: string) => null as string | null), mockGetCompliancesOverview: vi.fn(), - mockRouterPush: vi.fn(), + mockWindowOpen: vi.fn(), mockSearchParamsState: { value: "" }, })); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mockRouterPush, refresh: vi.fn() }), + useRouter: () => ({ refresh: vi.fn() }), usePathname: () => "/findings", useSearchParams: () => new URLSearchParams(mockSearchParamsState.value), redirect: vi.fn(), @@ -104,10 +105,30 @@ vi.mock("@/components/shadcn/card/card", () => ({ })); vi.mock("@/components/shadcn/dropdown", () => ({ - ActionDropdown: ({ children }: { children: ReactNode }) => ( -
{children}
+ ActionDropdown: ({ + children, + ariaLabel, + }: { + children: ReactNode; + ariaLabel?: string; + }) => ( +
+ {children} +
+ ), + ActionDropdownItem: ({ + label, + disabled, + onSelect, + }: { + label: string; + disabled?: boolean; + onSelect?: () => void; + }) => ( + ), - ActionDropdownItem: () => null, })); vi.mock("@/components/shadcn/skeleton/skeleton", () => ({ @@ -125,7 +146,25 @@ vi.mock("@/components/shadcn/tooltip", () => ({ })); vi.mock("@/components/findings/mute-findings-modal", () => ({ - MuteFindingsModal: () => null, + MuteFindingsModal: ({ + isOpen, + findingIds, + onComplete, + }: { + isOpen: boolean; + findingIds: string[]; + onComplete?: () => void; + }) => + isOpen + ? globalThis.document?.body && + // Render into body to mirror the real modal portal behavior. + createPortal( + , + globalThis.document.body, + ) + : null, })); vi.mock("@/components/findings/send-to-jira-modal", () => ({ @@ -547,9 +586,14 @@ describe("ResourceDetailDrawerContent — compliance icon styling", () => { }); describe("ResourceDetailDrawerContent — compliance navigation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("should resolve the clicked framework against the selected scan and navigate to compliance detail", async () => { // Given const user = userEvent.setup(); + vi.stubGlobal("open", mockWindowOpen); mockSearchParamsState.value = "filter[scan__in]=scan-selected&filter[region__in]=eu-west-1"; mockGetCompliancesOverview.mockResolvedValue({ @@ -595,14 +639,17 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => { expect(mockGetCompliancesOverview).toHaveBeenCalledWith({ scanId: "scan-selected", }); - expect(mockRouterPush).toHaveBeenCalledWith( + expect(mockWindowOpen).toHaveBeenCalledWith( "/compliance/PCI-DSS?complianceId=compliance-1&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1", + "_blank", + "noopener,noreferrer", ); }); it("should use the current finding scan when no scan filter is active", async () => { // Given const user = userEvent.setup(); + vi.stubGlobal("open", mockWindowOpen); mockGetCompliancesOverview.mockResolvedValue({ data: [ { @@ -662,8 +709,134 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => { expect(mockGetCompliancesOverview).toHaveBeenCalledWith({ scanId: "scan-from-finding", }); - expect(mockRouterPush).toHaveBeenCalledWith( + 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", + "_blank", + "noopener,noreferrer", + ); + }); + + it("should navigate when the finding framework is a short alias of the compliance overview framework", async () => { + // Given + const user = userEvent.setup(); + vi.stubGlobal("open", mockWindowOpen); + mockGetComplianceIcon.mockImplementation((framework: string) => + framework.toLowerCase().includes("kisa") ? "/kisa.svg" : null, + ); + mockGetCompliancesOverview.mockResolvedValue({ + data: [ + { + id: "compliance-kisa", + type: "compliance-overviews", + attributes: { + framework: "KISA-ISMS-P", + version: "1.0", + requirements_passed: 5, + requirements_failed: 1, + requirements_manual: 0, + total_requirements: 6, + }, + }, + ], + }); + const findingWithScan = { + ...mockFinding, + scan: { + id: "scan-from-finding", + name: "Nightly scan", + trigger: "manual", + state: "completed", + uniqueResourceCount: 25, + progress: 100, + duration: 300, + startedAt: "2026-03-30T10:00:00Z", + completedAt: "2026-03-30T10:05:00Z", + insertedAt: "2026-03-30T09:59:00Z", + scheduledAt: null, + }, + }; + + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { + name: "Open KISA compliance details", + }), + ); + + // Then + expect(mockGetCompliancesOverview).toHaveBeenCalledWith({ + 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", + "_blank", + "noopener,noreferrer", ); }); }); + +describe("ResourceDetailDrawerContent — other findings mute refresh", () => { + it("should update only the muted other-finding row without refreshing the current finding group", async () => { + // Given + const user = userEvent.setup(); + const onMuteComplete = vi.fn(); + const otherFinding: ResourceDrawerFinding = { + ...mockFinding, + id: "finding-2", + uid: "uid-2", + checkId: "ec2_check", + checkTitle: "EC2 Check", + updatedAt: "2026-03-30T10:05:00Z", + }; + + render( + , + ); + + // When + const row = screen.getByText("EC2 Check").closest("tr"); + expect(row).not.toBeNull(); + + await user.click( + within(row as HTMLElement).getByRole("button", { name: "Mute" }), + ); + await user.click( + screen.getByRole("button", { name: "Confirm mute finding-2" }), + ); + + // Then + expect( + within(row as HTMLElement).getByRole("button", { name: "Muted" }), + ).toBeDisabled(); + expect(onMuteComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 509f128b97..09bf786f07 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { useState } from "react"; import { getCompliancesOverview } from "@/actions/compliances"; @@ -84,7 +84,90 @@ function normalizeComplianceFrameworkName(framework: string): string { return framework .trim() .toLowerCase() - .replace(/[\s_]+/g, "-"); + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-"); +} + +function stripComplianceVersionSuffix(framework: string): string { + return framework.replace(/-\d+(?:\.\d+)*$/g, ""); +} + +function canonicalComplianceKey(framework: string): string { + return stripComplianceVersionSuffix( + normalizeComplianceFrameworkName(framework), + ) + .replace(/[^a-z0-9]+/g, "") + .trim(); +} + +function complianceTokens(framework: string): string[] { + return stripComplianceVersionSuffix( + normalizeComplianceFrameworkName(framework), + ) + .split("-") + .map((token) => token.trim()) + .filter(Boolean) + .filter((token) => !/^\d+(?:\.\d+)*$/.test(token)); +} + +function complianceMatchScore( + sourceFramework: string, + targetFramework: string, +): number { + const normalizedSource = normalizeComplianceFrameworkName(sourceFramework); + const normalizedTarget = normalizeComplianceFrameworkName(targetFramework); + + if (normalizedSource === normalizedTarget) { + return 5; + } + + const canonicalSource = canonicalComplianceKey(sourceFramework); + const canonicalTarget = canonicalComplianceKey(targetFramework); + + if (canonicalSource === canonicalTarget) { + return 4; + } + + if (canonicalSource && canonicalTarget) { + const sourceTokens = canonicalSource.split("-"); + const targetTokens = canonicalTarget.split("-"); + if ( + sourceTokens.length !== targetTokens.length && + (sourceTokens.every((t) => targetTokens.includes(t)) || + targetTokens.every((t) => sourceTokens.includes(t))) + ) { + return 3; + } + } + + const sourceTokens = complianceTokens(sourceFramework); + const targetTokens = complianceTokens(targetFramework); + if (!sourceTokens.length || !targetTokens.length) { + return 0; + } + + const sourceMatchesTarget = sourceTokens.every((token) => + targetTokens.includes(token), + ); + const targetMatchesSource = targetTokens.every((token) => + sourceTokens.includes(token), + ); + + if (sourceMatchesTarget || targetMatchesSource) { + return 2; + } + + if ( + sourceTokens.some((token) => targetTokens.includes(token)) && + canonicalSource && + canonicalTarget && + (canonicalTarget.includes(canonicalSource) || + canonicalSource.includes(canonicalTarget)) + ) { + return 1; + } + + return 0; } function parseSelectedScanIds(scanFilterValue: string | null): string[] { @@ -110,12 +193,13 @@ function resolveComplianceMatch( return null; } - const normalizedFramework = normalizeComplianceFrameworkName(framework); - const match = compliances.find( - (compliance) => - normalizeComplianceFrameworkName(compliance.attributes.framework) === - normalizedFramework, - ); + const match = compliances + .map((compliance) => ({ + compliance, + score: complianceMatchScore(framework, compliance.attributes.framework), + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score)[0]?.compliance; if (!match) { return null; @@ -202,13 +286,15 @@ export function ResourceDetailDrawerContent({ onNavigateNext, onMuteComplete, }: ResourceDetailDrawerContentProps) { - const router = useRouter(); const searchParams = useSearchParams(); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const [resolvingFramework, setResolvingFramework] = useState( null, ); + const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState< + Set + >(new Set()); // Initial load — no check metadata yet if (!checkMeta && isLoading) { @@ -284,7 +370,7 @@ export function ResourceDetailDrawerContent({ return; } - router.push( + window.open( buildComplianceDetailHref({ complianceId: complianceMatch.complianceId, framework: complianceMatch.framework, @@ -294,6 +380,8 @@ export function ResourceDetailDrawerContent({ currentFinding: f, includeScanData: f?.scan?.id === complianceScanId, }), + "_blank", + "noopener,noreferrer", ); } catch (error) { console.error("Error resolving compliance detail:", error); @@ -428,10 +516,10 @@ export function ResourceDetailDrawerContent({ )}
- {/* Navigation: "Impacted Resource (X of N)" */} + {/* Navigation: "Resource (X of N)" */}
- Impacted Resource + Resource {currentIndex + 1} of {totalResources} @@ -477,7 +565,7 @@ export function ResourceDetailDrawerContent({ /> } - entityAlias={f.resourceGroup} + entityAlias={f.resourceName} entityId={f.resourceUid} idLabel="UID" /> @@ -505,7 +593,9 @@ export function ResourceDetailDrawerContent({ {getFailingForLabel(f.firstSeenAt) || "-"} -
+ + {f.resourceGroup || "-"} + {/* Row 3: IDs */} @@ -529,6 +619,11 @@ export function ResourceDetailDrawerContent({ className="max-w-full text-sm" /> + + {/* Row 4: Resource metadata */} + + {f.resourceType || "-"} +
{/* Actions button — fixed size, aligned with row 1 */} @@ -757,10 +852,7 @@ export function ResourceDetailDrawerContent({
) : ( <> -
-

- Failed Findings For This Resource -

+
{otherFindings.length} Total Entries @@ -796,7 +888,18 @@ export function ResourceDetailDrawerContent({ {otherFindings.length > 0 ? ( otherFindings.map((finding) => ( - + + setOptimisticallyMutedIds((prev) => + new Set(prev).add(finding.id), + ) + } + /> )) ) : ( @@ -908,19 +1011,32 @@ export function ResourceDetailDrawerContent({ ); } -function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) { +function OtherFindingRow({ + finding, + isOptimisticallyMuted, + onMuted, +}: { + finding: ResourceDrawerFinding; + isOptimisticallyMuted: boolean; + onMuted: () => void; +}) { const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); + const isMuted = finding.isMuted || isOptimisticallyMuted; const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`; return ( <> - {!finding.isMuted && ( + {!isMuted && ( { + setIsMuteModalOpen(false); + onMuted(); + }} /> )} window.open(findingUrl, "_blank", "noopener,noreferrer")} > - + @@ -955,14 +1071,14 @@ function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) { ) : ( ) } - label={finding.isMuted ? "Muted" : "Mute"} - disabled={finding.isMuted} + label={isMuted ? "Muted" : "Mute"} + disabled={isMuted} onSelect={() => setIsMuteModalOpen(true)} /> ({ + Skeleton: ({ className }: { className?: string }) => ( +
+ ), +})); + +import { ResourceDetailSkeleton } from "./resource-detail-skeleton"; + +describe("ResourceDetailSkeleton", () => { + it("should include placeholders for group and resource type fields", () => { + render(); + + const blocks = screen.getAllByTestId("skeleton-block"); + const classes = blocks.map( + (block) => block.getAttribute("data-class") ?? "", + ); + + expect(classes).toContain("h-3.5 w-10 rounded"); + expect(classes).toContain("h-5 w-18 rounded"); + expect(classes).toContain("h-3.5 w-20 rounded"); + expect(classes).toContain("h-5 w-28 rounded"); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx index ed123983f5..9ef08ee14e 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx @@ -2,8 +2,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; /** * Skeleton placeholder for the resource info grid in the detail drawer. - * Mirrors the 4-column layout: EntityInfo × 2, InfoField × 2 per row, - * plus the actions button. + * Mirrors the drawer layout so added metadata fields don't leave visual gaps + * while the next resource is loading. */ export function ResourceDetailSkeleton() { return ( @@ -15,16 +15,19 @@ export function ResourceDetailSkeleton() { - {/* Row 2: Last detected, First seen, Failing for */} + {/* Row 2: Last detected, First seen, Failing for, Group */} -
+ {/* Row 3: Check ID, Finding ID, Finding UID */} + + {/* Row 4: Resource type */} +
{/* Actions button */} diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index 404be9b165..4ce921a4e8 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -26,6 +26,7 @@ vi.mock("next/navigation", () => ({ // Import after mocks // --------------------------------------------------------------------------- +import type { ResourceDrawerFinding } from "@/actions/findings"; import type { FindingResourceRow } from "@/types"; import { useResourceDetailDrawer } from "./use-resource-detail-drawer"; @@ -60,6 +61,46 @@ function makeResource( } as FindingResourceRow; } +function makeDrawerFinding( + overrides?: Partial, +): ResourceDrawerFinding { + return { + id: "finding-1", + uid: "uid-1", + checkId: "s3_check", + checkTitle: "S3 Check", + status: "FAIL", + severity: "high", + delta: null, + isMuted: false, + mutedReason: null, + firstSeenAt: null, + updatedAt: null, + resourceId: "resource-1", + resourceUid: "arn:aws:s3:::my-bucket", + resourceName: "my-bucket", + resourceService: "s3", + resourceRegion: "us-east-1", + resourceType: "bucket", + resourceGroup: "default", + providerType: "aws", + providerAlias: "prod", + providerUid: "123", + risk: "high", + description: "desc", + statusExtended: "status", + complianceFrameworks: [], + categories: [], + remediation: { + recommendation: { text: "", url: "" }, + code: { cli: "", other: "", nativeiac: "", terraform: "" }, + }, + additionalUrls: [], + scan: null, + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Fix 2: AbortController cleanup on unmount // --------------------------------------------------------------------------- @@ -128,3 +169,212 @@ describe("useResourceDetailDrawer — unmount cleanup", () => { expect(abortSpy).not.toHaveBeenCalled(); }); }); + +describe("useResourceDetailDrawer — other findings filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should exclude the current finding from otherFindings and preserve API order", async () => { + const resources = [makeResource()]; + + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + adaptFindingsByResourceResponseMock.mockReturnValue([ + makeDrawerFinding({ + id: "current", + checkId: "s3_check", + checkTitle: "Current", + status: "FAIL", + severity: "critical", + }), + makeDrawerFinding({ + id: "other-1", + checkId: "check-other-1", + checkTitle: "Other 1", + status: "PASS", + severity: "critical", + }), + makeDrawerFinding({ + id: "other-2", + checkId: "check-other-2", + checkTitle: "Other 2", + status: "FAIL", + severity: "medium", + }), + ]); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + checkId: "s3_check", + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([ + "other-1", + "other-2", + ]); + }); + + it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => { + vi.useFakeTimers(); + + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + resourceUid: "arn:aws:s3:::first-bucket", + resourceName: "first-bucket", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + resourceUid: "arn:aws:s3:::second-bucket", + resourceName: "second-bucket", + }), + ]; + + getLatestFindingsByResourceUidMock.mockImplementation( + async ({ resourceUid }: { resourceUid: string }) => ({ + data: [resourceUid], + }), + ); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => [ + makeDrawerFinding({ + id: response.data[0].includes("first") ? "finding-1" : "finding-2", + resourceUid: response.data[0], + resourceName: response.data[0].includes("first") + ? "first-bucket" + : "second-bucket", + }), + ], + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + checkId: "s3_check", + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + await act(async () => { + result.current.navigateNext(); + await Promise.resolve(); + }); + + expect(result.current.currentIndex).toBe(1); + expect(result.current.currentFinding?.id).toBe("finding-2"); + + act(() => { + result.current.navigatePrev(); + }); + + expect(result.current.currentIndex).toBe(0); + expect(result.current.isNavigating).toBe(true); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + expect(result.current.isNavigating).toBe(false); + expect(result.current.currentFinding?.id).toBe("finding-1"); + + vi.useRealTimers(); + }); + + it("should keep isNavigating true for a fast uncached navigation long enough to avoid flicker", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-08T15:00:00.000Z")); + + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + resourceUid: "arn:aws:s3:::first-bucket", + resourceName: "first-bucket", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + resourceUid: "arn:aws:s3:::second-bucket", + resourceName: "second-bucket", + }), + ]; + + getLatestFindingsByResourceUidMock.mockImplementation( + async ({ resourceUid }: { resourceUid: string }) => ({ + data: [resourceUid], + }), + ); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => [ + makeDrawerFinding({ + id: response.data[0].includes("first") ? "finding-1" : "finding-2", + resourceUid: response.data[0], + resourceName: response.data[0].includes("first") + ? "first-bucket" + : "second-bucket", + }), + ], + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + checkId: "s3_check", + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + act(() => { + result.current.navigateNext(); + }); + + expect(result.current.currentIndex).toBe(1); + expect(result.current.isNavigating).toBe(true); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.currentFinding?.id).toBe("finding-2"); + expect(result.current.isNavigating).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(119); + await Promise.resolve(); + }); + + expect(result.current.isNavigating).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + expect(result.current.isNavigating).toBe(false); + + vi.useRealTimers(); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index d1d9eb96d3..c9bf263dc0 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -9,6 +9,10 @@ import { } from "@/actions/findings"; import { FindingResourceRow } from "@/types"; +// Keep fast carousel navigations in a loading state for one short beat so +// React doesn't batch away the skeleton frame when switching resources. +const MIN_NAVIGATION_SKELETON_MS = 300; + /** * Check-level metadata that is identical across all resources for a given check. * Extracted once on first successful fetch and kept stable during navigation. @@ -83,18 +87,65 @@ export function useResourceDetailDrawer({ const cacheRef = useRef>(new Map()); const checkMetaRef = useRef(null); const fetchControllerRef = useRef(null); + const navigationTimeoutRef = useRef | null>( + null, + ); + const navigationStartedAtRef = useRef(null); + + const clearNavigationTimeout = () => { + if (navigationTimeoutRef.current !== null) { + clearTimeout(navigationTimeoutRef.current); + navigationTimeoutRef.current = null; + } + }; + + const finishNavigation = () => { + clearNavigationTimeout(); + setIsLoading(false); + + const navigationStartedAt = navigationStartedAtRef.current; + if (navigationStartedAt === null) { + navigationStartedAtRef.current = null; + setIsNavigating(false); + return; + } + + const elapsed = Date.now() - navigationStartedAt; + const remaining = Math.max(0, MIN_NAVIGATION_SKELETON_MS - elapsed); + + if (remaining === 0) { + navigationStartedAtRef.current = null; + setIsNavigating(false); + return; + } + + navigationTimeoutRef.current = setTimeout(() => { + setIsNavigating(false); + navigationStartedAtRef.current = null; + navigationTimeoutRef.current = null; + }, remaining); + }; + + const startNavigation = () => { + clearNavigationTimeout(); + navigationStartedAtRef.current = Date.now(); + setIsNavigating(true); + }; // Abort any in-flight request on unmount to prevent state updates // on an already-unmounted component. useEffect(() => { return () => { fetchControllerRef.current?.abort(); + clearNavigationTimeout(); + navigationStartedAtRef.current = null; }; }, []); const fetchFindings = async (resourceUid: string) => { // Abort any in-flight request to prevent stale data from out-of-order responses fetchControllerRef.current?.abort(); + clearNavigationTimeout(); const controller = new AbortController(); fetchControllerRef.current = controller; @@ -106,8 +157,7 @@ export function useResourceDetailDrawer({ if (main) checkMetaRef.current = extractCheckMeta(main); } setFindings(cached); - setIsLoading(false); - setIsNavigating(false); + finishNavigation(); return; } @@ -135,8 +185,7 @@ export function useResourceDetailDrawer({ } } finally { if (!controller.signal.aborted) { - setIsLoading(false); - setIsNavigating(false); + finishNavigation(); } } }; @@ -145,8 +194,11 @@ export function useResourceDetailDrawer({ const resource = resources[index]; if (!resource) return; + clearNavigationTimeout(); + navigationStartedAtRef.current = null; setCurrentIndex(index); setIsOpen(true); + setIsNavigating(false); setFindings([]); fetchFindings(resource.resourceUid); }; @@ -159,7 +211,7 @@ export function useResourceDetailDrawer({ const resource = resources[currentIndex]; if (!resource) return; cacheRef.current.delete(resource.resourceUid); - setIsNavigating(true); + startNavigation(); fetchFindings(resource.resourceUid); }; @@ -168,7 +220,7 @@ export function useResourceDetailDrawer({ if (!resource) return; setCurrentIndex(index); - setIsNavigating(true); + startNavigation(); fetchFindings(resource.resourceUid); }; diff --git a/ui/components/scans/scans-filters.tsx b/ui/components/scans/scans-filters.tsx index 6273acac77..60dbca0bfc 100644 --- a/ui/components/scans/scans-filters.tsx +++ b/ui/components/scans/scans-filters.tsx @@ -3,20 +3,23 @@ import { X } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { ScanSelector } from "@/components/compliance/compliance-header"; import { filterScans } from "@/components/filters/data-filters"; import { FilterControls } from "@/components/filters/filter-controls"; import { Badge } from "@/components/shadcn/badge/badge"; import { useRelatedFilters } from "@/hooks"; -import { FilterEntity, FilterType } from "@/types"; +import { ExpandedScanData, FilterEntity, FilterType } from "@/types"; interface ScansFiltersProps { providerUIDs: string[]; providerDetails: { [uid: string]: FilterEntity }[]; + completedScans?: ExpandedScanData[]; } export const ScansFilters = ({ providerUIDs, providerDetails, + completedScans = [], }: ScansFiltersProps) => { const router = useRouter(); const pathname = usePathname(); @@ -36,24 +39,50 @@ export const ScansFilters = ({ router.push(`${pathname}?${params.toString()}`); }; - const scanIdChip = idFilter ? ( -
- - Scan: - {idFilter} + const handleScanChange = (selectedScanId: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("filter[id__in]", selectedScanId); + router.push(`${pathname}?${params.toString()}`); + }; + + const scanIdElement = idFilter ? ( + completedScans.length > 0 ? ( +
+ - -
+
+ ) : ( +
+ + + Scan: + + {idFilter} + + +
+ ) ) : null; return ( @@ -68,7 +97,7 @@ export const ScansFilters = ({ index: 1, }, ]} - prependElement={scanIdChip} + prependElement={scanIdElement} /> ); }; diff --git a/ui/components/shadcn/card/card.tsx b/ui/components/shadcn/card/card.tsx index 8226ae698f..7c60a51f5f 100644 --- a/ui/components/shadcn/card/card.tsx +++ b/ui/components/shadcn/card/card.tsx @@ -20,7 +20,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", { inner: "rounded-[12px] backdrop-blur-[46px] border-border-neutral-tertiary bg-bg-neutral-tertiary", danger: - "gap-1 rounded-[12px] border-border-error-primary bg-bg-fail-secondary", + "gap-1 rounded-[12px] border-[rgba(67,34,50,0.5)] bg-[rgba(67,34,50,0.2)] dark:border-[rgba(67,34,50,0.7)] dark:bg-[rgba(67,34,50,0.3)]", }, padding: { default: "", diff --git a/ui/components/ui/entities/date-with-time.tsx b/ui/components/ui/entities/date-with-time.tsx index fd43fdbd5a..90a801e9ed 100644 --- a/ui/components/ui/entities/date-with-time.tsx +++ b/ui/components/ui/entities/date-with-time.tsx @@ -1,5 +1,10 @@ import { format, parseISO } from "date-fns"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { cn } from "@/lib/utils"; interface DateWithTimeProps { @@ -33,25 +38,52 @@ export const DateWithTime = ({ ?.substring(0, 3) .toUpperCase() || ""; - return ( + const fullText = showTime + ? `${formattedDate} ${formattedTime} ${timezone}` + : formattedDate; + + const content = (
- + {formattedDate} {showTime && ( - + {formattedTime} {timezone} )}
); + + if (inline) { + return ( + + +
{content}
+
+ {fullText} +
+ ); + } + + return content; } catch { return -; } diff --git a/ui/hooks/use-infinite-resources.test.ts b/ui/hooks/use-infinite-resources.test.ts index 49ab0f9105..618de56fba 100644 --- a/ui/hooks/use-infinite-resources.test.ts +++ b/ui/hooks/use-infinite-resources.test.ts @@ -163,6 +163,38 @@ describe("useInfiniteResources", () => { findingGroupActionsMock.getLatestFindingGroupResources, ).not.toHaveBeenCalled(); }); + + it("should forward the active finding-group filters to the resources endpoint", async () => { + // Given + const apiResponse = makeApiResponse([], { pages: 1 }); + const filters = { + "filter[status__in]": "PASS", + "filter[severity__in]": "medium", + "filter[provider_type__in]": "aws", + }; + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + [], + ); + + // When + renderHook(() => useInfiniteResources(defaultOptions({ filters }))); + await flushAsync(); + + // Then + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).toHaveBeenCalledWith( + expect.objectContaining({ + checkId: "check_1", + page: 1, + pageSize: 10, + filters, + }), + ); + }); }); describe("when all resources fit in one page", () => { diff --git a/ui/hooks/use-infinite-resources.ts b/ui/hooks/use-infinite-resources.ts index fc720dd9f7..df710ebf10 100644 --- a/ui/hooks/use-infinite-resources.ts +++ b/ui/hooks/use-infinite-resources.ts @@ -32,6 +32,8 @@ interface UseInfiniteResourcesReturn { refresh: () => void; /** Imperatively load the next page (e.g. from drawer navigation). */ loadMore: () => void; + /** Total number of resources matching current filters (from API pagination). */ + totalCount: number | null; } /** @@ -60,6 +62,7 @@ export function useInfiniteResources({ const currentCheckIdRef = useRef(checkId); const controllerRef = useRef(null); const observerRef = useRef(null); + const totalCountRef = useRef(null); // Store latest values in refs so the fetch function always reads current values // without being recreated on every render @@ -70,6 +73,7 @@ export function useInfiniteResources({ const onSetLoadingRef = useRef(onSetLoading); // Keep refs in sync with latest props + currentCheckIdRef.current = checkId; hasDateOrScanRef.current = hasDateOrScanFilter; filtersRef.current = filters; onSetResourcesRef.current = onSetResources; @@ -110,6 +114,7 @@ export function useInfiniteResources({ ); const totalPages = response?.meta?.pagination?.pages ?? 1; const hasMore = page < totalPages; + totalCountRef.current = response?.meta?.pagination?.count ?? null; // Commit the page number only after a successful (non-aborted) fetch. // This prevents a premature pageRef increment from loadNextPage being @@ -209,5 +214,10 @@ export function useInfiniteResources({ fetchPage(1, false, currentCheckIdRef.current, controller.signal); } - return { sentinelRef, refresh, loadMore: loadNextPage }; + return { + sentinelRef, + refresh, + loadMore: loadNextPage, + totalCount: totalCountRef.current, + }; } diff --git a/ui/lib/findings-scan-filters.test.ts b/ui/lib/findings-scan-filters.test.ts new file mode 100644 index 0000000000..fcf32b507d --- /dev/null +++ b/ui/lib/findings-scan-filters.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildFindingScanDateFilters, + resolveFindingScanDateFilters, +} from "./findings-scan-filters"; + +describe("buildFindingScanDateFilters", () => { + it("uses an exact inserted_at filter when all selected scans belong to the same day", () => { + expect( + buildFindingScanDateFilters([ + "2026-04-07T10:00:00Z", + "2026-04-07T18:30:00Z", + ]), + ).toEqual({ + "filter[inserted_at]": "2026-04-07", + }); + }); + + it("ignores whitespace-only date strings", () => { + expect(buildFindingScanDateFilters([" ", "2026-04-07T10:00:00Z"])).toEqual( + { + "filter[inserted_at]": "2026-04-07", + }, + ); + }); + + it("uses a date range when selected scans span multiple days", () => { + expect( + buildFindingScanDateFilters([ + "2026-04-03T10:00:00Z", + "2026-04-07T18:30:00Z", + "2026-04-05T12:00:00Z", + ]), + ).toEqual({ + "filter[inserted_at__gte]": "2026-04-03", + "filter[inserted_at__lte]": "2026-04-07", + }); + }); +}); + +describe("resolveFindingScanDateFilters", () => { + it("adds the required inserted_at filter for a selected scan when the URL only contains scan__in", async () => { + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[muted]": "false", + "filter[scan__in]": "scan-1", + }, + scans: [ + { + id: "scan-1", + attributes: { + inserted_at: "2026-04-07T10:00:00Z", + }, + }, + ], + loadScan: vi.fn(), + }); + + expect(result).toEqual({ + "filter[muted]": "false", + "filter[scan__in]": "scan-1", + "filter[inserted_at]": "2026-04-07", + }); + }); + + it("fetches missing scan details when the selected scan is not present in the prefetched scans list", async () => { + const loadScan = vi.fn().mockResolvedValue({ + id: "scan-2", + attributes: { + inserted_at: "2026-04-05T08:00:00Z", + }, + }); + + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[scan__in]": "scan-2", + }, + scans: [], + loadScan, + }); + + expect(loadScan).toHaveBeenCalledWith("scan-2"); + expect(result).toEqual({ + "filter[scan__in]": "scan-2", + "filter[inserted_at]": "2026-04-05", + }); + }); + + it("does not override an explicit inserted_at filter already chosen in the frontend", async () => { + const result = await resolveFindingScanDateFilters({ + filters: { + "filter[scan__in]": "scan-1", + "filter[inserted_at__gte]": "2026-04-01", + }, + scans: [ + { + id: "scan-1", + attributes: { + inserted_at: "2026-04-07T10:00:00Z", + }, + }, + ], + loadScan: vi.fn(), + }); + + expect(result).toEqual({ + "filter[scan__in]": "scan-1", + "filter[inserted_at__gte]": "2026-04-01", + }); + }); +}); diff --git a/ui/lib/findings-scan-filters.ts b/ui/lib/findings-scan-filters.ts new file mode 100644 index 0000000000..dfbb1bc44c --- /dev/null +++ b/ui/lib/findings-scan-filters.ts @@ -0,0 +1,99 @@ +interface ScanDateSource { + id: string; + attributes?: { + inserted_at?: string; + }; +} + +interface ResolveFindingScanDateFiltersOptions { + filters: Record; + scans: ScanDateSource[]; + loadScan: (scanId: string) => Promise; +} + +const INSERTED_AT_FILTER_KEYS = [ + "filter[inserted_at]", + "filter[inserted_at__date]", + "filter[inserted_at__gte]", + "filter[inserted_at__lte]", +] as const; + +function getScanFilterIds(filters: Record): string[] { + const scanIds = filters["filter[scan__in]"] || filters["filter[scan]"] || ""; + return Array.from(new Set(scanIds.split(",").filter(Boolean))); +} + +function formatScanDate(dateTime?: string): string | null { + if (!dateTime) return null; + const [date] = dateTime.split("T"); + return date?.trim() || null; +} + +function hasInsertedAtFilter(filters: Record): boolean { + return INSERTED_AT_FILTER_KEYS.some((key) => Boolean(filters[key])); +} + +export function buildFindingScanDateFilters( + scanInsertedAtValues: string[], +): Record { + const dates = Array.from( + new Set(scanInsertedAtValues.map(formatScanDate).filter(Boolean)), + ).sort() as string[]; + + if (dates.length === 0) { + return {}; + } + + if (dates.length === 1) { + return { + "filter[inserted_at]": dates[0], + }; + } + + return { + "filter[inserted_at__gte]": dates[0], + "filter[inserted_at__lte]": dates[dates.length - 1], + }; +} + +export async function resolveFindingScanDateFilters({ + filters, + scans, + loadScan, +}: ResolveFindingScanDateFiltersOptions): Promise> { + const scanIds = getScanFilterIds(filters); + + if (scanIds.length === 0 || hasInsertedAtFilter(filters)) { + return filters; + } + + const scansById = new Map(scans.map((scan) => [scan.id, scan])); + const missingScanIds = scanIds.filter((scanId) => !scansById.has(scanId)); + + if (missingScanIds.length > 0) { + const missingScans = await Promise.all( + missingScanIds.map((scanId) => loadScan(scanId)), + ); + + missingScans.forEach((scan) => { + if (scan) { + scansById.set(scan.id, scan); + } + }); + } + + const scanInsertedAtValues = scanIds + .map((scanId) => scansById.get(scanId)?.attributes?.inserted_at) + .filter((insertedAt): insertedAt is string => Boolean(insertedAt)); + + const dateFilters = buildFindingScanDateFilters(scanInsertedAtValues); + + if (Object.keys(dateFilters).length === 0) { + return filters; + } + + return { + ...filters, + ...dateFilters, + }; +} diff --git a/ui/types/findings-table.ts b/ui/types/findings-table.ts index 30198404f5..f8837884f0 100644 --- a/ui/types/findings-table.ts +++ b/ui/types/findings-table.ts @@ -34,12 +34,14 @@ export interface FindingResourceRow { providerAlias: string; providerUid: string; resourceName: string; + resourceType: string; resourceGroup: string; resourceUid: string; service: string; region: string; severity: Severity; status: string; + delta?: string | null; isMuted: boolean; mutedReason?: string; firstSeenAt: string | null;