From bf1b53bbd297e0e7d95d9a565d0399a972a593df Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Mon, 20 Apr 2026 13:34:31 +0200 Subject: [PATCH] fix(ui): sorting and filtering for findings (#10778) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: alejandrobailo --- ui/CHANGELOG.md | 12 +- .../findings/findings-by-resource.test.ts | 11 +- ui/actions/findings/findings-by-resource.ts | 3 +- .../findings-view/findings-view.ssr.tsx | 26 ++-- .../graphs-tabs/graphs-tabs-wrapper.tsx | 8 +- ui/app/(prowler)/findings/page.test.ts | 6 +- ui/app/(prowler)/findings/page.tsx | 14 +- .../client-accordion-content.tsx | 1 + ui/components/filters/custom-date-picker.tsx | 8 +- ui/components/findings/findings-filters.tsx | 53 +------ .../findings/findings-filters.utils.test.ts | 109 ++++++++++++- .../findings/findings-filters.utils.ts | 78 +++++++++- .../table/column-standalone-findings.tsx | 55 ++++--- .../resource-detail-drawer-content.test.tsx | 84 +++++++++- .../resource-detail-drawer-content.tsx | 14 +- .../use-resource-detail-drawer.test.ts | 14 +- .../use-resource-detail-drawer.ts | 6 +- .../link-to-findings.test.tsx | 48 ++++++ .../link-to-findings/link-to-findings.tsx | 21 +-- .../table/skeleton-table-new-findings.tsx | 143 +++++++++++++----- .../table/scans/column-get-scans.test.ts | 22 +++ .../scans/table/scans/column-get-scans.tsx | 70 ++++----- .../scans/data-table-download-details.tsx | 34 ----- .../ui/table/data-table-filter-custom.tsx | 15 +- ui/components/ui/table/data-table.tsx | 4 + ui/hooks/use-filter-batch.test.ts | 37 +++++ ui/lib/date-utils.test.ts | 35 +++++ ui/lib/date-utils.ts | 24 ++- ui/lib/findings-scan-filters.test.ts | 6 +- ui/lib/findings-scan-filters.ts | 18 ++- ui/lib/helper-filters.test.ts | 64 ++++++++ ui/lib/helper-filters.ts | 27 +++- ui/types/filters.ts | 6 +- 33 files changed, 808 insertions(+), 268 deletions(-) create mode 100644 ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx create mode 100644 ui/components/scans/table/scans/column-get-scans.test.ts delete mode 100644 ui/components/scans/table/scans/data-table-download-details.tsx create mode 100644 ui/lib/date-utils.test.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index be12653904..9a83d91fea 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,14 +4,18 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.24.1] (Prowler v5.24.1) -### 🔒 Security - -- Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization [(#10754)](https://github.com/prowler-cloud/prowler/pull/10754) - ### 🐞 Fixed - Findings and filter UX fixes: exclude muted findings by default in the resource detail drawer and finding group resource views, show category context label (for example `Status: FAIL`) on MultiSelect triggers instead of hiding the placeholder, and add a `wide` width option for filter dropdowns applied to the findings Scan filter to prevent label truncation [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734) - Findings grouped view now handles zero-resource IaC counters, refines drawer loading states, and adds provider indicators to finding groups [(#10736)](https://github.com/prowler-cloud/prowler/pull/10736) +- Other Findings for this resource: ordering by `severity` [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Other Findings for this resource: show `delta` indicator [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Compliance: requirement findings do not show muted findings [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Latest new findings: link to finding groups order by `-severity,-last_seen_at` [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) + +### 🔒 Security + +- Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization [(#10754)](https://github.com/prowler-cloud/prowler/pull/10754) --- diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts index 3a045d2e75..7bc2793195 100644 --- a/ui/actions/findings/findings-by-resource.test.ts +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -272,7 +272,7 @@ describe("getLatestFindingsByResourceUid", () => { handleApiResponseMock.mockResolvedValue({ data: [] }); }); - it("should exclude muted findings by default and always apply severity/time sorting", async () => { + it("should restrict to FAIL, exclude muted findings, and apply severity/time sorting by default", async () => { fetchMock.mockResolvedValue(new Response("", { status: 200 })); await getLatestFindingsByResourceUid({ @@ -284,8 +284,12 @@ describe("getLatestFindingsByResourceUid", () => { expect(calledUrl.searchParams.get("filter[resource_uid]")).toBe( "resource-1", ); + // Status filter is applied server-side so the page[size]=50 window + // always holds FAIL rows — guards against PASS-heavy resources + // starving FAILs out of the result. + expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); expect(calledUrl.searchParams.get("filter[muted]")).toBe("false"); - expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at"); + expect(calledUrl.searchParams.get("sort")).toBe("severity,-updated_at"); }); it("should include muted findings only when explicitly requested", async () => { @@ -297,7 +301,8 @@ describe("getLatestFindingsByResourceUid", () => { }); const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); expect(calledUrl.searchParams.get("filter[muted]")).toBe("include"); - expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at"); + expect(calledUrl.searchParams.get("sort")).toBe("severity,-updated_at"); }); }); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index ee6d952cf0..74a0bcb6de 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -264,8 +264,9 @@ export const getLatestFindingsByResourceUid = async ({ ); url.searchParams.append("filter[resource_uid]", resourceUid); + url.searchParams.append("filter[status]", "FAIL"); url.searchParams.append("filter[muted]", includeMuted ? "include" : "false"); - url.searchParams.append("sort", "-severity,-updated_at"); + url.searchParams.append("sort", "severity,-updated_at"); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 9554869dce..cca971578c 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -4,6 +4,7 @@ import { getLatestFindings } from "@/actions/findings/findings"; import { LighthouseBanner } from "@/components/lighthouse/banner"; import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; +import { CardTitle } from "@/components/shadcn"; import { DataTable } from "@/components/ui/table"; import { createDict } from "@/lib/helper"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -57,24 +58,23 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { }; return ( -
+
-
-
-

- Latest new failing findings -

-

- Showing the latest 10 new failing findings by severity. -

- -
-
- +
+ Latest New Failed Findings +

+ Showing the latest 10 sorted by severity +

+
+ +
+ } />
); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx b/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx index c21491e37f..f9741dc75e 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx @@ -1,6 +1,7 @@ import { Skeleton } from "@heroui/skeleton"; import { Suspense } from "react"; +import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table"; import { SearchParamsProps } from "@/types"; import { GraphsTabsClient } from "./_components/graphs-tabs-client"; @@ -18,6 +19,10 @@ const LoadingFallback = () => ( ); +const TAB_FALLBACKS: Partial> = { + findings: , +}; + type GraphComponent = React.ComponentType<{ searchParams: SearchParamsProps }>; const GRAPH_COMPONENTS: Record = { @@ -38,9 +43,10 @@ export const GraphsTabsWrapper = async ({ const tabsContent = Object.fromEntries( GRAPH_TABS.map((tab) => { const Component = GRAPH_COMPONENTS[tab.id]; + const fallback = TAB_FALLBACKS[tab.id] ?? ; return [ tab.id, - }> + , ]; diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 76462dff99..444ed47f9e 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -25,8 +25,10 @@ describe("findings page", () => { expect(source).toContain("resolveFindingScanDateFilters"); }); - it("uses getLatestFindingGroups for non-date/scan queries and getFindingGroups for historical", () => { - expect(source).toContain("hasDateOrScan"); + it("uses resolved filters to choose getFindingGroups for historical queries and getLatestFindingGroups otherwise", () => { + expect(source).toContain("hasHistoricalData"); + expect(source).toContain("hasDateOrScanFilter(filtersWithScanDates)"); + expect(source).toContain("hasDateOrScanFilter(filters)"); expect(source).toContain("getFindingGroups"); expect(source).toContain("getLatestFindingGroups"); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 948d264602..22c90330bb 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -34,9 +34,6 @@ export default async function Findings({ const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); - // Check if the searchParams contain any date or scan filter - const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - const [providersData, scansData] = await Promise.all([ getProviders({ pageSize: 50 }), getScans({ pageSize: 50 }), @@ -51,8 +48,10 @@ export default async function Findings({ }, }); + const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates); + const metadataInfoData = await ( - hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo + hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo )({ query, sort: encodedSort, @@ -119,10 +118,9 @@ const SSRDataTable = async ({ const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); const { encodedSort } = extractSortAndKey(searchParams); - // Check if the searchParams contain any date or scan filter - const hasDateOrScan = hasDateOrScanFilter(searchParams); + const hasHistoricalData = hasDateOrScanFilter(filters); - const fetchFindingGroups = hasDateOrScan + const fetchFindingGroups = hasHistoricalData ? getFindingGroups : getLatestFindingGroups; @@ -151,7 +149,7 @@ const SSRDataTable = async ({ data={groups} metadata={findingGroupsData?.meta} resolvedFilters={filters} - hasHistoricalData={hasDateOrScan} + hasHistoricalData={hasHistoricalData} /> ); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index f582f2b3c2..659d3a83fa 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -62,6 +62,7 @@ export const ClientAccordionContent = ({ filters: { "filter[check_id__in]": checkIds.join(","), "filter[scan]": scanId, + "filter[muted]": "false", ...(region && { "filter[region__in]": region }), }, page: parseInt(pageNumber, 10), diff --git a/ui/components/filters/custom-date-picker.tsx b/ui/components/filters/custom-date-picker.tsx index 3e92615b5c..effe2ef144 100644 --- a/ui/components/filters/custom-date-picker.tsx +++ b/ui/components/filters/custom-date-picker.tsx @@ -12,6 +12,7 @@ import { PopoverTrigger, } from "@/components/shadcn/popover"; import { useUrlFilters } from "@/hooks/use-url-filters"; +import { toLocalDateString } from "@/lib/date-utils"; import { cn } from "@/lib/utils"; /** Batch mode: caller controls both the pending date value and the notification callback (all-or-nothing). */ @@ -67,17 +68,14 @@ export const CustomDatePicker = ({ const applyDateFilter = (selectedDate: Date | undefined) => { if (onBatchChange) { // Batch mode: notify caller instead of updating URL - onBatchChange( - "inserted_at", - selectedDate ? format(selectedDate, "yyyy-MM-dd") : "", - ); + onBatchChange("inserted_at", toLocalDateString(selectedDate) ?? ""); return; } // Instant mode (default): push to URL immediately if (selectedDate) { // Format as YYYY-MM-DD for the API - updateFilter("inserted_at", format(selectedDate, "yyyy-MM-dd")); + updateFilter("inserted_at", toLocalDateString(selectedDate) ?? ""); } else { updateFilter("inserted_at", null); } diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 61008d311a..57d2711166 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -20,10 +20,13 @@ import { DataTableFilterCustom } from "@/components/ui/table"; import { useFilterBatch } from "@/hooks/use-filter-batch"; import { getCategoryLabel, getGroupLabel } from "@/lib/categories"; import { FilterType, ScanEntity } from "@/types"; -import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters"; +import { DATA_TABLE_FILTER_MODE } from "@/types/filters"; import { ProviderProps } from "@/types/providers"; -import { getFindingsFilterDisplayValue } from "./findings-filters.utils"; +import { + buildFindingsFilterChips, + getFindingsFilterDisplayValue, +} from "./findings-filters.utils"; interface FindingsFiltersProps { /** Provider data for ProviderTypeSelector and AccountsSelector */ @@ -37,30 +40,6 @@ interface FindingsFiltersProps { uniqueGroups: string[]; } -/** - * Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels. - * Used to render chips in the FilterSummaryStrip. - * Typed as Record so TypeScript enforces exhaustiveness — any - * addition to FilterParam will cause a compile error here if the label is missing. - */ -const FILTER_KEY_LABELS: Record = { - "filter[provider_type__in]": "Provider", - "filter[provider_id__in]": "Account", - "filter[severity__in]": "Severity", - "filter[status__in]": "Status", - "filter[delta__in]": "Delta", - "filter[region__in]": "Region", - "filter[service__in]": "Service", - "filter[resource_type__in]": "Resource Type", - "filter[category__in]": "Category", - "filter[resource_groups__in]": "Resource Group", - "filter[scan__in]": "Scan", - "filter[scan_id]": "Scan", - "filter[scan_id__in]": "Scan", - "filter[inserted_at]": "Date", - "filter[muted]": "Muted", -}; - export const FindingsFilters = ({ providers, completedScanIds, @@ -145,25 +124,9 @@ export const FindingsFilters = ({ const hasCustomFilters = customFilters.length > 0; - // Build FilterChip[] from pendingFilters — one chip per individual value, not per key. - // Skip filter[muted]="false" — it is the silent default and should not appear as a chip. - const filterChips: FilterChip[] = []; - Object.entries(pendingFilters).forEach(([key, values]) => { - if (!values || values.length === 0) return; - const label = FILTER_KEY_LABELS[key as FilterParam] ?? key; - values.forEach((value) => { - // Do not show a chip for the default muted=false state - if (key === "filter[muted]" && value === "false") return; - filterChips.push({ - key, - label, - value, - displayValue: getFindingsFilterDisplayValue(key, value, { - providers, - scans: scanDetails, - }), - }); - }); + const filterChips: FilterChip[] = buildFindingsFilterChips(pendingFilters, { + providers, + scans: scanDetails, }); // Handler for removing a single chip: update the pending filter to remove that value. diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index b4dc63c85b..9fbd56bf63 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; import { ProviderProps } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; -import { getFindingsFilterDisplayValue } from "./findings-filters.utils"; +import { + buildFindingsFilterChips, + getFindingsFilterDisplayValue, +} from "./findings-filters.utils"; function makeProvider( overrides: Partial & { id: string }, @@ -98,7 +101,7 @@ describe("getFindingsFilterDisplayValue", () => { 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("Nightly scan"); + ).toBe("AWS - Nightly scan"); }); it("normalizes finding statuses for display", () => { @@ -119,7 +122,17 @@ describe("getFindingsFilterDisplayValue", () => { ); }); - it("falls back to the scan provider uid when the alias is missing", () => { + it("formats the singular delta filter the same as delta__in", () => { + // The API registers the filter as `filter[delta]` (exact), not `delta__in`. + // Both shapes must resolve to the same human label so chips don't show + // the raw "new" going through formatLabel ("NEW" via the 3-letter acronym heuristic). + expect(getFindingsFilterDisplayValue("filter[delta]", "new")).toBe("New"); + expect(getFindingsFilterDisplayValue("filter[delta]", "changed")).toBe( + "Changed", + ); + }); + + it("uses the provider display name regardless of account alias/uid", () => { expect( getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", { scans: [ @@ -133,17 +146,17 @@ describe("getFindingsFilterDisplayValue", () => { }), ], }), - ).toBe("Weekly scan"); + ).toBe("AWS - Weekly scan"); }); - it("falls back to the provider alias when the scan name is missing", () => { + it("returns only the provider name when the scan name is missing", () => { expect( getFindingsFilterDisplayValue("filter[scan__in]", "scan-3", { scans: [ ...scans, makeScanMap("scan-3", { providerInfo: { - provider: "aws", + provider: "gcp", alias: "Fallback Account", uid: "333333333333", }, @@ -154,7 +167,7 @@ describe("getFindingsFilterDisplayValue", () => { }), ], }), - ).toBe("Fallback Account"); + ).toBe("Google Cloud"); }); it("keeps the raw scan value when the scan cannot be resolved", () => { @@ -185,3 +198,85 @@ describe("getFindingsFilterDisplayValue", () => { ).toBe("2026-04-07"); }); }); + +describe("buildFindingsFilterChips", () => { + it("creates one chip per value with normalized labels", () => { + // Given — this is the exact pending state derived from the LinkToFindings URL: + // /findings?sort=...&filter[status__in]=FAIL&filter[delta]=new + const pendingFilters = { + "filter[status__in]": ["FAIL"], + "filter[delta]": ["new"], + }; + + // When + const chips = buildFindingsFilterChips(pendingFilters); + + // Then — both chips must appear; the delta chip must use "Delta" as label + // (not the raw "filter[delta]") and "New" as displayValue (not "NEW" via + // the short-word acronym heuristic in formatLabel). + expect(chips).toEqual([ + { + key: "filter[status__in]", + label: "Status", + value: "FAIL", + displayValue: "Fail", + }, + { + key: "filter[delta]", + label: "Delta", + value: "new", + displayValue: "New", + }, + ]); + }); + + it("treats filter[delta] and filter[delta__in] identically", () => { + // Given + const chipsSingular = buildFindingsFilterChips({ + "filter[delta]": ["new", "changed"], + }); + const chipsPlural = buildFindingsFilterChips({ + "filter[delta__in]": ["new", "changed"], + }); + + // Then — both shapes produce the same human labels and display values + expect( + chipsSingular.map((c) => ({ label: c.label, v: c.displayValue })), + ).toEqual([ + { label: "Delta", v: "New" }, + { label: "Delta", v: "Changed" }, + ]); + expect( + chipsPlural.map((c) => ({ label: c.label, v: c.displayValue })), + ).toEqual([ + { label: "Delta", v: "New" }, + { label: "Delta", v: "Changed" }, + ]); + }); + + it("skips the silent default filter[muted]=false", () => { + const chips = buildFindingsFilterChips({ + "filter[muted]": ["false"], + "filter[delta]": ["new"], + }); + + // Only the delta chip — the default muted=false should not surface + expect(chips).toHaveLength(1); + expect(chips[0].key).toBe("filter[delta]"); + }); + + it("surfaces unmapped keys using the raw key as label (fallback)", () => { + const chips = buildFindingsFilterChips({ + "filter[unknown_future_key]": ["value"], + }); + + expect(chips).toEqual([ + { + key: "filter[unknown_future_key]", + label: "filter[unknown_future_key]", + value: "value", + displayValue: "Value", + }, + ]); + }); +}); diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 2599650c1d..0b8f752f69 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -1,5 +1,8 @@ +import type { FilterChip } from "@/components/filters/filter-summary-strip"; import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories"; +import { getScanEntityLabel } from "@/lib/helper-filters"; import { FINDING_STATUS_DISPLAY_NAMES } from "@/types"; +import { FilterParam } from "@/types/filters"; import { getProviderDisplayName, ProviderProps } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; import { SEVERITY_DISPLAY_NAMES } from "@/types/severities"; @@ -35,12 +38,7 @@ function getScanDisplayValue( return scanId; } - return ( - scan.attributes.name || - scan.providerInfo.alias || - scan.providerInfo.uid || - scanId - ); + return getScanEntityLabel(scan) || scanId; } export function getFindingsFilterDisplayValue( @@ -55,7 +53,7 @@ export function getFindingsFilterDisplayValue( if (filterKey === "filter[provider_id__in]") { return getProviderAccountDisplayValue(value, options.providers || []); } - if (filterKey === "filter[scan__in]") { + if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") { return getScanDisplayValue(value, options.scans || []); } if (filterKey === "filter[severity__in]") { @@ -72,7 +70,7 @@ export function getFindingsFilterDisplayValue( ] ?? formatLabel(value) ); } - if (filterKey === "filter[delta__in]") { + if (filterKey === "filter[delta__in]" || filterKey === "filter[delta]") { return ( FINDING_DELTA_DISPLAY_NAMES[value.toLowerCase()] ?? formatLabel(value) ); @@ -93,3 +91,67 @@ export function getFindingsFilterDisplayValue( return formatLabel(value); } + +/** + * Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels. + * Used to render chips in the FilterSummaryStrip. + * Typed as Record so TypeScript enforces exhaustiveness — any + * addition to FilterParam will cause a compile error here if the label is missing. + */ +export const FILTER_KEY_LABELS: Record = { + "filter[provider_type__in]": "Provider", + "filter[provider_id__in]": "Account", + "filter[severity__in]": "Severity", + "filter[status__in]": "Status", + "filter[delta__in]": "Delta", + "filter[delta]": "Delta", + "filter[region__in]": "Region", + "filter[service__in]": "Service", + "filter[resource_type__in]": "Resource Type", + "filter[category__in]": "Category", + "filter[resource_groups__in]": "Resource Group", + "filter[scan]": "Scan", + "filter[scan__in]": "Scan", + "filter[scan_id]": "Scan", + "filter[scan_id__in]": "Scan", + "filter[inserted_at]": "Date", + "filter[muted]": "Muted", +}; + +interface BuildFindingsFilterChipsOptions { + providers?: ProviderProps[]; + scans?: Array<{ [scanId: string]: ScanEntity }>; +} + +/** + * Builds the chips displayed in the FilterSummaryStrip from a pendingFilters map. + * + * - One chip per individual value (not one per key), so a multi-select filter + * produces multiple chips. + * - Silently skips the default `filter[muted]=false` so it doesn't appear as a + * user-applied filter. + * - Falls back to the raw key as label for unmapped keys, so an unexpected + * param still surfaces instead of disappearing. + */ +export function buildFindingsFilterChips( + pendingFilters: Record, + options: BuildFindingsFilterChipsOptions = {}, +): FilterChip[] { + const chips: FilterChip[] = []; + + Object.entries(pendingFilters).forEach(([key, values]) => { + if (!values || values.length === 0) return; + const label = FILTER_KEY_LABELS[key as FilterParam] ?? key; + values.forEach((value) => { + if (key === "filter[muted]" && value === "false") return; + chips.push({ + key, + label, + value, + displayValue: getFindingsFilterDisplayValue(key, value, options), + }); + }); + }); + + return chips; +} diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx index 9854f05d9d..cdd924a0b0 100644 --- a/ui/components/findings/table/column-standalone-findings.tsx +++ b/ui/components/findings/table/column-standalone-findings.tsx @@ -1,15 +1,15 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Database } from "lucide-react"; +import { Container } from "lucide-react"; -import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; -import { DateWithTime } from "@/components/ui/entities"; +import { DateWithTime, EntityInfo } from "@/components/ui/entities"; import { DataTableColumnHeader, SeverityBadge, StatusFindingBadge, } from "@/components/ui/table"; +import { getRegionFlag } from "@/lib/region-flags"; import { FindingProps, ProviderType } from "@/types"; import { FindingDetailDrawer } from "./finding-detail-drawer"; @@ -126,18 +126,25 @@ export function getStandaloneFindingColumns({ ), cell: ({ row }) => { - const resourceName = getResourceData(row, "name"); - - if (resourceName === "-") { - return

-

; - } + const name = getResourceData(row, "name"); + const uid = getResourceData(row, "uid"); + const entityAlias = + typeof name === "string" && name.trim().length > 0 && name !== "-" + ? name + : undefined; + const entityId = + typeof uid === "string" && uid.trim().length > 0 && uid !== "-" + ? uid + : undefined; return ( - `...${value.slice(-10)}`} - icon={} - /> +
+ } + entityAlias={entityAlias} + entityId={entityId} + /> +
); }, enableSorting: false, @@ -161,12 +168,17 @@ export function getStandaloneFindingColumns({ { accessorKey: "provider", header: ({ column }) => ( - + ), cell: ({ row }) => { const provider = getProviderData(row, "provider"); - return ; + return ( + + ); }, enableSorting: false, }, @@ -193,10 +205,17 @@ export function getStandaloneFindingColumns({ cell: ({ row }) => { const region = getResourceData(row, "region"); const regionText = typeof region === "string" ? region : "-"; + const regionFlag = + typeof region === "string" ? getRegionFlag(region) : ""; return ( -

- {regionText} -

+ + {regionFlag && ( + + {regionFlag} + + )} + {regionText} + ); }, enableSorting: false, 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 bdc104efbe..bebc9b0c4a 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 @@ -14,12 +14,14 @@ const { mockWindowOpen, mockClipboardWriteText, mockSearchParamsState, + mockNotificationIndicator, } = vi.hoisted(() => ({ mockGetComplianceIcon: vi.fn((_: string) => null as string | null), mockGetCompliancesOverview: vi.fn(), mockWindowOpen: vi.fn(), mockClipboardWriteText: vi.fn(), mockSearchParamsState: { value: "" }, + mockNotificationIndicator: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -298,7 +300,11 @@ vi.mock("../delta-indicator", () => ({ })); vi.mock("../notification-indicator", () => ({ - NotificationIndicator: () => null, + NotificationIndicator: (props: Record) => { + mockNotificationIndicator(props); + return null; + }, + DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" } as const, })); vi.mock("./resource-detail-skeleton", () => ({ @@ -1348,3 +1354,79 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", () ).toBeInTheDocument(); }); }); + +describe("ResourceDetailDrawerContent — other findings delta/muted indicator", () => { + const renderWithOtherFinding = ( + overrides: Partial, + ) => { + const otherFinding: ResourceDrawerFinding = { + ...mockFinding, + id: "finding-2", + uid: "uid-2", + checkId: "ec2_check", + checkTitle: "EC2 Check", + ...overrides, + }; + render( + , + ); + }; + + const lastNotificationIndicatorPropsForOtherRow = () => { + const calls = mockNotificationIndicator.mock.calls; + // Last call corresponds to the other-finding row (current finding row renders first). + return calls[calls.length - 1][0]; + }; + + it("should forward delta='new' to the NotificationIndicator for a new other finding", () => { + renderWithOtherFinding({ delta: "new" }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: "new", + isMuted: false, + showDeltaWhenMuted: true, + }); + }); + + it("should forward delta='changed' to the NotificationIndicator for a changed other finding", () => { + renderWithOtherFinding({ delta: "changed" }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: "changed", + }); + }); + + it("should pass delta=undefined when the finding has delta='none'", () => { + renderWithOtherFinding({ delta: "none" }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: undefined, + }); + }); + + it("should forward mutedReason and keep delta when a muted other finding is also new", () => { + renderWithOtherFinding({ + delta: "new", + isMuted: true, + mutedReason: "False positive", + }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: "new", + isMuted: true, + mutedReason: "False positive", + showDeltaWhenMuted: true, + }); + }); +}); 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 626f33bfbd..6042d5a39f 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 @@ -74,7 +74,7 @@ import type { FindingResourceRow } from "@/types/findings-table"; import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; -import { NotificationIndicator } from "../notification-indicator"; +import { DeltaValues, NotificationIndicator } from "../notification-indicator"; import { ResourceDetailSkeleton } from "./resource-detail-skeleton"; import type { CheckMeta } from "./use-resource-detail-drawer"; @@ -1313,7 +1313,17 @@ function OtherFindingRow({ onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")} > - + 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 f767184598..9c5a731a51 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 @@ -176,10 +176,12 @@ describe("useResourceDetailDrawer — other findings filtering", () => { getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); }); - it("should load other findings from the current resource uid and exclude the current finding", async () => { + it("should load other findings from the current resource uid and exclude only the current finding (status is filtered server-side)", async () => { const resources = [makeResource()]; - // Given + // Given — the API call applies filter[status]=FAIL server-side, so the + // mock returns only FAIL rows. The hook's only client-side job is to + // drop the row already shown above the table. getFindingByIdMock.mockResolvedValue({ data: ["detail"] }); getLatestFindingsByResourceUidMock.mockResolvedValue({ data: ["resource"], @@ -192,7 +194,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => { id: "finding-1", checkId: "s3_check", checkTitle: "Current", - status: "MANUAL", + status: "FAIL", severity: "informational", }), ]; @@ -211,12 +213,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => { status: "FAIL", severity: "critical", }), - makeDrawerFinding({ - id: "finding-4", - checkTitle: "Manual finding should be filtered out", - status: "MANUAL", - severity: "low", - }), makeDrawerFinding({ id: "finding-5", checkTitle: "Second other finding", 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 7a85b3d994..1a4a9c37f9 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 @@ -228,10 +228,10 @@ export function useResourceDetailDrawer({ : null; setCurrentFinding(nextCurrentFinding); + // The API already filters to status=FAIL (see getLatestFindingsByResourceUid). + // Only need to drop the current finding from the list. setOtherFindings( - nextOtherFindings.filter( - (finding) => finding.id !== findingId && finding.status === "FAIL", - ), + nextOtherFindings.filter((finding) => finding.id !== findingId), ); } catch (_error) { if (!controller.signal.aborted) { diff --git a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx new file mode 100644 index 0000000000..fff67495d4 --- /dev/null +++ b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...rest + }: { + children: ReactNode; + href: string; + "aria-label"?: string; + className?: string; + }) => ( + + {children} + + ), +})); + +import { LinkToFindings } from "./link-to-findings"; + +describe("LinkToFindings", () => { + it("should link to findings sorted by severity (desc) then last_seen_at (desc), filtered to FAIL + new delta", () => { + render(); + + const link = screen.getByRole("link", { name: "Go to Findings page" }); + const href = link.getAttribute("href") ?? ""; + const [, query = ""] = href.split("?"); + const params = new URLSearchParams(query); + + expect(params.get("sort")).toBe("-severity,-last_seen_at"); + expect(params.get("filter[status__in]")).toBe("FAIL"); + // filter[delta] must be singular — the finding-groups filter does not + // register `delta__in`, so the plural form is silently dropped by the API. + expect(params.get("filter[delta]")).toBe("new"); + expect(params.has("filter[delta__in]")).toBe(false); + }); + + it("should render as a tertiary text link (not a solid button) to match the overview Card pattern", () => { + render(); + + const link = screen.getByRole("link", { name: "Go to Findings page" }); + expect(link.className).toContain("text-button-tertiary"); + expect(link.className).toContain("hover:text-button-tertiary-hover"); + }); +}); diff --git a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx index 74272133ce..41b9f69e75 100644 --- a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx +++ b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx @@ -1,20 +1,13 @@ -"use client"; - import Link from "next/link"; -import { Button } from "@/components/shadcn/button/button"; - export const LinkToFindings = () => { return ( -
- -
+ + Check out on Findings + ); }; diff --git a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx index 7783e7ab2d..df5017712e 100644 --- a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx +++ b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx @@ -1,39 +1,114 @@ -import React from "react"; - -import { Card } from "@/components/shadcn/card/card"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -export const SkeletonTableNewFindings = () => { - const columns = 7; - const rows = 3; - +const SkeletonTableRow = () => { return ( - - {/* Table headers */} -
- {Array.from({ length: columns }).map((_, index) => ( - - ))} -
- - {/* Table body */} -
- {Array.from({ length: rows }).map((_, rowIndex) => ( -
- {Array.from({ length: columns }).map((_, colIndex) => ( - - ))} -
- ))} -
-
+ + {/* Notification dot */} + + + + {/* Status badge */} + + + + {/* Finding title */} + + + + {/* Resource name */} + +
+ + +
+ + {/* Severity badge */} + + + + {/* Provider icon */} + + + + {/* Service */} + + + + {/* Region — flag + name */} + +
+ + +
+ + {/* Time */} + + + + + ); +}; + +export const SkeletonTableNewFindings = () => { + const rows = 10; + + return ( +
+ {/* Header: title + description on the left, link on the right */} +
+
+ + +
+ +
+ + {/* Table */} + + + + {/* Notification header (no text) */} + + {/* Finding */} + + {/* Resource name */} + + {/* Severity */} + + {/* Cloud Provider */} + + {/* Service */} + + {/* Region */} + + {/* Time */} + + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+ {/* Status */} + + + + + + + + + + + + + + + + +
+
); }; diff --git a/ui/components/scans/table/scans/column-get-scans.test.ts b/ui/components/scans/table/scans/column-get-scans.test.ts new file mode 100644 index 0000000000..88757c5d98 --- /dev/null +++ b/ui/components/scans/table/scans/column-get-scans.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("column-get-scans", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "column-get-scans.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("links scan findings to the historical finding-groups filters", () => { + expect(source).toContain("filter[scan]="); + expect(source).toContain("filter[inserted_at]="); + expect(source).not.toContain("filter[scan__in]"); + }); + + it("links the findings filter against the scan's completed_at (what the backend expects)", () => { + expect(source).toMatch(/attributes:\s*{\s*completed_at\s*}/); + expect(source).toMatch(/toLocalDateString\(completed_at\)/); + }); +}); diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 5f55628b29..e9a073ac1c 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -8,10 +8,10 @@ import { TableLink } from "@/components/ui/custom"; import { DateWithTime, EntityInfo } from "@/components/ui/entities"; import { TriggerSheet } from "@/components/ui/sheet"; import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; +import { toLocalDateString } from "@/lib/date-utils"; import { ProviderType, ScanProps } from "@/types"; import { TriggerIcon } from "../../trigger-icon"; -import { DataTableDownloadDetails } from "./data-table-download-details"; import { DataTableRowActions } from "./data-table-row-actions"; import { DataTableRowDetails } from "./data-table-row-details"; @@ -97,24 +97,6 @@ export const ColumnGetScans: ColumnDef[] = [ enableSorting: false, }, - { - accessorKey: "started_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { started_at }, - } = getScanData(row); - - return ( -
- -
- ); - }, - enableSorting: false, - }, { accessorKey: "status", header: ({ column }) => ( @@ -141,12 +123,22 @@ export const ColumnGetScans: ColumnDef[] = [ ), cell: ({ row }) => { - const { id } = getScanData(row); + const { + id, + attributes: { completed_at }, + } = getScanData(row); const scanState = row.original.attributes?.state; + // Source is `completed_at` (scan finish time) because findings are + // persisted when the scan ends — that's when their `inserted_at` is + // written. The URL key stays `filter[inserted_at]` because the findings + // table is partitioned by the finding's `inserted_at` date; this filter + // is the partition hint the backend uses to avoid scanning every + // partition. Names differ by design: scan.completed_at ≈ finding.inserted_at. + const scanDate = toLocalDateString(completed_at); return ( ); @@ -171,24 +163,10 @@ export const ColumnGetScans: ColumnDef[] = [ }, enableSorting: false, }, - { - id: "download", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( -
- -
- ); - }, - enableSorting: false, - }, { accessorKey: "resources", header: ({ column }) => ( - + ), cell: ({ row }) => { const { @@ -202,6 +180,24 @@ export const ColumnGetScans: ColumnDef[] = [ }, enableSorting: false, }, + { + accessorKey: "started_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { started_at }, + } = getScanData(row); + + return ( +
+ +
+ ); + }, + enableSorting: false, + }, { accessorKey: "scheduled_at", header: ({ column }) => ( diff --git a/ui/components/scans/table/scans/data-table-download-details.tsx b/ui/components/scans/table/scans/data-table-download-details.tsx deleted file mode 100644 index 4579ef3c56..0000000000 --- a/ui/components/scans/table/scans/data-table-download-details.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Row } from "@tanstack/react-table"; -import { useState } from "react"; - -import { DownloadIconButton, useToast } from "@/components/ui"; -import { downloadScanZip } from "@/lib"; - -interface DataTableDownloadDetailsProps { - row: Row; -} - -export function DataTableDownloadDetails({ - row, -}: DataTableDownloadDetailsProps) { - const { toast } = useToast(); - const [isDownloading, setIsDownloading] = useState(false); - - const scanId = (row.original as { id: string }).id; - const scanState = (row.original as any).attributes?.state; - - const handleDownload = async () => { - setIsDownloading(true); - await downloadScanZip(scanId, toast); - setIsDownloading(false); - }; - - return ( - - ); -} diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index da95dd7aff..28988b34d8 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -15,7 +15,11 @@ import { } from "@/components/shadcn/select/multiselect"; import { EntityInfo } from "@/components/ui/entities/entity-info"; import { useUrlFilters } from "@/hooks/use-url-filters"; -import { isConnectionStatus, isScanEntity } from "@/lib/helper-filters"; +import { + getScanEntityLabel, + isConnectionStatus, + isScanEntity, +} from "@/lib/helper-filters"; import { cn } from "@/lib/utils"; import { FilterEntity, @@ -84,10 +88,11 @@ export const DataTableFilterCustom = ({ if (!entity) return value; if (isScanEntity(entity as ScanEntity)) { - const scanEntity = entity as ScanEntity; - return ( - scanEntity.providerInfo?.alias || scanEntity.providerInfo?.uid || value - ); + // Match the summary-strip chip: "Scan: {provider} - {name}". Without the + // "Scan:" prefix, the trigger badge would just say "AWS Prod - Nightly", + // which reads as a generic account tag and hides that it's a scan filter. + const label = getScanEntityLabel(entity as ScanEntity); + return label ? `Scan: ${label}` : value; } if (isConnectionStatus(entity)) { const connectionStatus = entity as ProviderConnectionStatus; diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index 339a5ab6d0..7cf78271a0 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -110,6 +110,8 @@ interface DataTableProviderProps { searchBadge?: { label: string; onDismiss: () => void }; /** Optional click handler for top-level rows. */ onRowClick?: (row: Row) => void; + /** Optional header rendered inside the table container, above the toolbar. */ + header?: ReactNode; } export function DataTable({ @@ -140,6 +142,7 @@ export function DataTable({ renderAfterRow, searchBadge, onRowClick, + header, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -235,6 +238,7 @@ export function DataTable({ isPending && "pointer-events-none opacity-60", )} > + {header &&
{header}
} {/* Table Toolbar */} {showToolbar && (
diff --git a/ui/hooks/use-filter-batch.test.ts b/ui/hooks/use-filter-batch.test.ts index 675316402e..8107b7d357 100644 --- a/ui/hooks/use-filter-batch.test.ts +++ b/ui/hooks/use-filter-batch.test.ts @@ -61,6 +61,43 @@ describe("useFilterBatch", () => { }); expect(result.current.hasChanges).toBe(false); }); + + it("should expose filter[delta]=new under the FilterType.DELTA key so the dropdown shows it selected", async () => { + // Given — URL from LinkToFindings uses `filter[delta]` (singular), matching the API. + setSearchParams({ + "filter[status__in]": "FAIL", + "filter[delta]": "new", + }); + + const { FilterType } = await import("@/types/filters"); + + // When + const { result } = renderHook(() => useFilterBatch()); + + // Then — the Delta dropdown reads via getFilterValue(`filter[${FilterType.DELTA}]`). + // For the checkbox of "new" to appear checked, that lookup must return ["new"]. + expect( + result.current.getFilterValue(`filter[${FilterType.DELTA}]`), + ).toEqual(["new"]); + }); + + it("should include both filter[status__in] and filter[delta] from the overview deep link", () => { + // Given — URL produced by LinkToFindings: /findings?...&filter[status__in]=FAIL&filter[delta]=new + setSearchParams({ + "filter[status__in]": "FAIL", + "filter[delta]": "new", + }); + + // When + const { result } = renderHook(() => useFilterBatch()); + + // Then — the singular `filter[delta]` key must be captured in pendingFilters + // so FindingsFilters can render a chip for it (same as filter[status__in]). + expect(result.current.pendingFilters).toEqual({ + "filter[status__in]": ["FAIL"], + "filter[delta]": ["new"], + }); + }); }); // ── Excluded keys ────────────────────────────────────────────────────────── diff --git a/ui/lib/date-utils.test.ts b/ui/lib/date-utils.test.ts new file mode 100644 index 0000000000..894506ffd1 --- /dev/null +++ b/ui/lib/date-utils.test.ts @@ -0,0 +1,35 @@ +import { format, parseISO } from "date-fns"; +import { describe, expect, it } from "vitest"; + +import { toLocalDateString } from "./date-utils"; + +describe("toLocalDateString", () => { + it("returns undefined for nullish or empty input", () => { + expect(toLocalDateString(undefined)).toBeUndefined(); + expect(toLocalDateString(null)).toBeUndefined(); + expect(toLocalDateString("")).toBeUndefined(); + }); + + it("returns undefined for malformed strings", () => { + expect(toLocalDateString("not-a-date")).toBeUndefined(); + }); + + it("returns undefined for invalid Date instances", () => { + expect(toLocalDateString(new Date("not-a-date"))).toBeUndefined(); + }); + + it("formats an ISO string in the user's local timezone", () => { + // Near UTC midnight — the UTC split ("2026-04-19") differs from the local + // date for any tz with a positive offset. We pin parity with date-fns so + // the assertion holds regardless of where CI runs. + const iso = "2026-04-19T23:15:00Z"; + const expected = format(parseISO(iso), "yyyy-MM-dd"); + + expect(toLocalDateString(iso)).toBe(expected); + }); + + it("formats a Date instance using its local calendar day", () => { + const date = new Date(2026, 3, 20, 10, 0, 0); // April 20, 2026 local + expect(toLocalDateString(date)).toBe("2026-04-20"); + }); +}); diff --git a/ui/lib/date-utils.ts b/ui/lib/date-utils.ts index 40717b4e14..7bce896580 100644 --- a/ui/lib/date-utils.ts +++ b/ui/lib/date-utils.ts @@ -1,4 +1,26 @@ -import { formatDistanceToNow } from "date-fns"; +import { format, formatDistanceToNow, parseISO } from "date-fns"; + +/** + * Formats an ISO string or Date into a `yyyy-MM-dd` string in the user's local + * timezone. Mirrors the format used by `DateWithTime`, so UI chips/URLs built + * with this helper match what the user sees in tables and pickers. Returns + * undefined for null, empty, or malformed input so callers can guard on it + * (e.g. `isDisabled={!toLocalDateString(x)}`). Do NOT use this for UTC-based + * date bucketing (e.g. chart axes partitioned server-side by UTC day) — that + * use case needs a separate UTC helper. + */ +export function toLocalDateString( + value: string | Date | null | undefined, +): string | undefined { + if (!value) return undefined; + try { + const date = typeof value === "string" ? parseISO(value) : value; + if (isNaN(date.getTime())) return undefined; + return format(date, "yyyy-MM-dd"); + } catch { + return undefined; + } +} /** * Formats a duration in seconds to a human-readable string like "2h 5m 30s". diff --git a/ui/lib/findings-scan-filters.test.ts b/ui/lib/findings-scan-filters.test.ts index fcf32b507d..a1e4d4a4e0 100644 --- a/ui/lib/findings-scan-filters.test.ts +++ b/ui/lib/findings-scan-filters.test.ts @@ -50,7 +50,7 @@ describe("resolveFindingScanDateFilters", () => { { id: "scan-1", attributes: { - inserted_at: "2026-04-07T10:00:00Z", + completed_at: "2026-04-07T10:00:00Z", }, }, ], @@ -68,7 +68,7 @@ describe("resolveFindingScanDateFilters", () => { const loadScan = vi.fn().mockResolvedValue({ id: "scan-2", attributes: { - inserted_at: "2026-04-05T08:00:00Z", + completed_at: "2026-04-05T08:00:00Z", }, }); @@ -97,7 +97,7 @@ describe("resolveFindingScanDateFilters", () => { { id: "scan-1", attributes: { - inserted_at: "2026-04-07T10:00:00Z", + completed_at: "2026-04-07T10:00:00Z", }, }, ], diff --git a/ui/lib/findings-scan-filters.ts b/ui/lib/findings-scan-filters.ts index dfbb1bc44c..a5984cb141 100644 --- a/ui/lib/findings-scan-filters.ts +++ b/ui/lib/findings-scan-filters.ts @@ -1,7 +1,11 @@ interface ScanDateSource { id: string; attributes?: { - inserted_at?: string; + // Findings are persisted when the scan finishes, so their `inserted_at` + // aligns with the scan's `completed_at` — not the scan's `inserted_at` + // (which is when the scan row was first created and can fall on a + // different UTC day for scans that cross midnight). + completed_at?: string; }; } @@ -34,10 +38,10 @@ function hasInsertedAtFilter(filters: Record): boolean { } export function buildFindingScanDateFilters( - scanInsertedAtValues: string[], + scanCompletedAtValues: string[], ): Record { const dates = Array.from( - new Set(scanInsertedAtValues.map(formatScanDate).filter(Boolean)), + new Set(scanCompletedAtValues.map(formatScanDate).filter(Boolean)), ).sort() as string[]; if (dates.length === 0) { @@ -82,11 +86,11 @@ export async function resolveFindingScanDateFilters({ }); } - const scanInsertedAtValues = scanIds - .map((scanId) => scansById.get(scanId)?.attributes?.inserted_at) - .filter((insertedAt): insertedAt is string => Boolean(insertedAt)); + const scanCompletedAtValues = scanIds + .map((scanId) => scansById.get(scanId)?.attributes?.completed_at) + .filter((completedAt): completedAt is string => Boolean(completedAt)); - const dateFilters = buildFindingScanDateFilters(scanInsertedAtValues); + const dateFilters = buildFindingScanDateFilters(scanCompletedAtValues); if (Object.keys(dateFilters).length === 0) { return filters; diff --git a/ui/lib/helper-filters.test.ts b/ui/lib/helper-filters.test.ts index fcff4a85d8..858a95e68f 100644 --- a/ui/lib/helper-filters.test.ts +++ b/ui/lib/helper-filters.test.ts @@ -1,16 +1,39 @@ import { describe, expect, it } from "vitest"; +import type { ScanEntity } from "@/types/scans"; + import { + getScanEntityLabel, hasDateFilter, hasDateOrScanFilter, hasHistoricalFindingFilter, } from "./helper-filters"; +function makeScan(overrides: Partial = {}): ScanEntity { + return { + id: "scan-1", + providerInfo: { + provider: "aws", + alias: "Production", + uid: "123456789012", + }, + attributes: { + name: "Nightly scan", + completed_at: "2026-04-07T10:00:00Z", + }, + ...overrides, + }; +} + describe("hasDateOrScanFilter", () => { it("returns true for scan filters", () => { expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true); }); + it("returns true for exact scan filters", () => { + expect(hasDateOrScanFilter({ "filter[scan]": "scan-1" })).toBe(true); + }); + it("returns true for inserted_at filters", () => { expect( hasDateOrScanFilter({ "filter[inserted_at__gte]": "2026-04-01" }), @@ -30,6 +53,43 @@ describe("hasDateFilter", () => { }); }); +describe("getScanEntityLabel", () => { + it("combines provider display name and scan name with a dash", () => { + expect(getScanEntityLabel(makeScan())).toBe("AWS - Nightly scan"); + }); + + it("uses the provider type even when the account alias is present", () => { + // Guard against regressions where alias/uid leak back into the label. + expect( + getScanEntityLabel( + makeScan({ + providerInfo: { + provider: "azure", + alias: "Production", + uid: "subscription-xyz", + }, + }), + ), + ).toBe("Azure - Nightly scan"); + }); + + it("renders the provider display name for non-AWS providers", () => { + expect( + getScanEntityLabel(makeScan({ providerInfo: { provider: "gcp" } })), + ).toBe("Google Cloud - Nightly scan"); + }); + + it("returns only the provider name when the scan name is missing", () => { + expect( + getScanEntityLabel( + makeScan({ + attributes: { name: "", completed_at: "2026-04-07T10:00:00Z" }, + }), + ), + ).toBe("AWS"); + }); +}); + describe("hasHistoricalFindingFilter", () => { it("returns true for inserted_at filters", () => { expect( @@ -43,6 +103,10 @@ describe("hasHistoricalFindingFilter", () => { ); }); + it("returns true for exact scan filters", () => { + expect(hasHistoricalFindingFilter({ "filter[scan]": "scan-1" })).toBe(true); + }); + it("returns false when neither date nor scan filters are active", () => { expect( hasHistoricalFindingFilter({ "filter[provider_type__in]": "aws" }), diff --git a/ui/lib/helper-filters.ts b/ui/lib/helper-filters.ts index 7280deb53d..ae510bdc83 100644 --- a/ui/lib/helper-filters.ts +++ b/ui/lib/helper-filters.ts @@ -1,6 +1,10 @@ import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types"; import { FilterEntity } from "@/types/filters"; -import { GroupFilterEntity, ProviderConnectionStatus } from "@/types/providers"; +import { + getProviderDisplayName, + GroupFilterEntity, + ProviderConnectionStatus, +} from "@/types/providers"; import { ScanEntity } from "@/types/scans"; /** @@ -31,7 +35,10 @@ export const extractFiltersAndQuery = ( */ export const hasDateOrScanFilter = (searchParams: Record) => Object.keys(searchParams).some( - (key) => key.includes("inserted_at") || key.includes("scan__in"), + (key) => + key.includes("inserted_at") || + key.includes("scan__in") || + key === "filter[scan]", ); /** @@ -96,6 +103,22 @@ export const isScanEntity = (entity: ScanEntity) => { return entity && entity.providerInfo && entity.attributes; }; +/** + * Canonical human label for a scan entity: "{Provider name} - {scan name}". + * Provider name comes from `getProviderDisplayName` (e.g. "AWS", "Google Cloud"), + * never the account alias/uid — those identify the account, not the provider. + * Shared by the findings filter chips and the multi-select trigger badge so + * both surfaces stay in sync. Returns the provider name alone when the scan + * name is empty, or the scan name alone if the provider type doesn't resolve. + */ +export function getScanEntityLabel(scan: ScanEntity): string { + const providerLabel = getProviderDisplayName(scan.providerInfo.provider); + const scanName = scan.attributes.name || ""; + + if (providerLabel && scanName) return `${providerLabel} - ${scanName}`; + return providerLabel || scanName; +} + /** * Creates a scan details mapping for filters from completed scans. * Used to provide detailed information for scan filters in the UI. diff --git a/ui/types/filters.ts b/ui/types/filters.ts index ccaf7f2c53..9630318712 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -39,7 +39,9 @@ export enum FilterType { RESOURCE_TYPE = "resource_type__in", SEVERITY = "severity__in", STATUS = "status__in", - DELTA = "delta__in", + // The API only registers `delta` (exact, singular). `delta__in` is silently + // dropped, so the dropdown, URL, and backend must all use `delta`. + DELTA = "delta", CATEGORY = "category__in", RESOURCE_GROUPS = "resource_groups__in", } @@ -68,11 +70,13 @@ export type FilterParam = | "filter[severity__in]" | "filter[status__in]" | "filter[delta__in]" + | "filter[delta]" | "filter[region__in]" | "filter[service__in]" | "filter[resource_type__in]" | "filter[category__in]" | "filter[resource_groups__in]" + | "filter[scan]" | "filter[scan__in]" | "filter[scan_id]" | "filter[scan_id__in]"