fix(ui): sorting and filtering for findings (#10778)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pepe Fagoaga
2026-04-20 13:34:31 +02:00
committed by GitHub
parent 94a2ea1e8f
commit bf1b53bbd2
33 changed files with 808 additions and 268 deletions
+8 -4
View File
@@ -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)
---
@@ -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");
});
});
+2 -1
View File
@@ -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());
@@ -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 (
<div className="flex w-full flex-col gap-6">
<div className="flex w-full flex-col">
<LighthouseBanner />
<div className="relative w-full flex-col justify-between md:flex-row">
<div className="flex w-full flex-col items-start gap-2 md:flex-row md:items-center">
<h3 className="text-sm font-bold text-nowrap whitespace-nowrap uppercase">
Latest new failing findings
</h3>
<p className="text-text-neutral-tertiary text-xs whitespace-nowrap">
Showing the latest 10 new failing findings by severity.
</p>
<LinkToFindings />
</div>
</div>
<DataTable
key={`dashboard-findings-${Date.now()}`}
columns={ColumnLatestFindings}
data={(expandedResponse?.data || []) as FindingProps[]}
header={
<div className="flex w-full items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<CardTitle>Latest New Failed Findings</CardTitle>
<p className="text-text-neutral-tertiary text-xs">
Showing the latest 10 sorted by severity
</p>
</div>
<LinkToFindings />
</div>
}
/>
</div>
);
@@ -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 = () => (
</div>
);
const TAB_FALLBACKS: Partial<Record<TabId, React.ReactNode>> = {
findings: <SkeletonTableNewFindings />,
};
type GraphComponent = React.ComponentType<{ searchParams: SearchParamsProps }>;
const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
@@ -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] ?? <LoadingFallback />;
return [
tab.id,
<Suspense key={tab.id} fallback={<LoadingFallback />}>
<Suspense key={tab.id} fallback={fallback}>
<Component searchParams={searchParams} />
</Suspense>,
];
+4 -2
View File
@@ -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");
});
+6 -8
View File
@@ -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}
/>
</>
);
@@ -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),
+3 -5
View File
@@ -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);
}
+8 -45
View File
@@ -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<FilterParam, string> so TypeScript enforces exhaustiveness — any
* addition to FilterParam will cause a compile error here if the label is missing.
*/
const FILTER_KEY_LABELS: Record<FilterParam, string> = {
"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.
@@ -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<ProviderProps> & { 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",
},
]);
});
});
@@ -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<FilterParam, string> 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<FilterParam, string> = {
"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<string, string[]>,
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;
}
@@ -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({
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
if (resourceName === "-") {
return <p className="text-text-neutral-primary text-sm">-</p>;
}
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 (
<CodeSnippet
value={resourceName as string}
formatter={(value: string) => `...${value.slice(-10)}`}
icon={<Database size={16} />}
/>
<div className="max-w-[240px]">
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={entityAlias}
entityId={entityId}
/>
</div>
);
},
enableSorting: false,
@@ -161,12 +168,17 @@ export function getStandaloneFindingColumns({
{
accessorKey: "provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Provider" />
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
return <ProviderIconCell provider={provider as ProviderType} />;
return (
<ProviderIconCell
provider={provider as ProviderType}
className="size-8"
/>
);
},
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 (
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{regionText}
</p>
<span className="text-text-neutral-primary flex max-w-[140px] items-center gap-1.5 truncate text-sm">
{regionFlag && (
<span className="translate-y-px text-base leading-none">
{regionFlag}
</span>
)}
<span className="truncate">{regionText}</span>
</span>
);
},
enableSorting: false,
@@ -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<string, unknown>) => {
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<ResourceDrawerFinding>,
) => {
const otherFinding: ResourceDrawerFinding = {
...mockFinding,
id: "finding-2",
uid: "uid-2",
checkId: "ec2_check",
checkTitle: "EC2 Check",
...overrides,
};
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[otherFinding]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
};
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,
});
});
});
@@ -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")}
>
<TableCell className="w-10">
<NotificationIndicator isMuted={isMuted} />
<NotificationIndicator
isMuted={isMuted}
delta={
finding.delta === DeltaValues.NEW ||
finding.delta === DeltaValues.CHANGED
? finding.delta
: undefined
}
mutedReason={finding.mutedReason ?? undefined}
showDeltaWhenMuted
/>
</TableCell>
<TableCell>
<StatusFindingBadge status={finding.status as FindingStatus} />
@@ -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",
@@ -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) {
@@ -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;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
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(<LinkToFindings />);
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(<LinkToFindings />);
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");
});
});
@@ -1,20 +1,13 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/shadcn/button/button";
export const LinkToFindings = () => {
return (
<div className="mt-4 flex w-full items-center justify-end">
<Button asChild variant="default" size="sm">
<Link
href="/findings?sort=severity,-inserted_at&filter[status__in]=FAIL&filter[delta__in]=new"
aria-label="Go to Findings page"
>
Check out on Findings
</Link>
</Button>
</div>
<Link
href="/findings?sort=-severity,-last_seen_at&filter[status__in]=FAIL&filter[delta]=new"
aria-label="Go to Findings page"
className="text-button-tertiary hover:text-button-tertiary-hover text-sm font-medium transition-colors"
>
Check out on Findings
</Link>
);
};
@@ -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 (
<Card variant="base" padding="md" className="flex flex-col gap-4">
{/* Table headers */}
<div className="flex gap-4">
{Array.from({ length: columns }).map((_, index) => (
<Skeleton
key={`header-${index}`}
className="h-8"
style={{ width: `${100 / columns}%` }}
/>
))}
</div>
{/* Table body */}
<div className="flex flex-col gap-3">
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={`row-${rowIndex}`} className="flex gap-4">
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={`cell-${rowIndex}-${colIndex}`}
className="h-12"
style={{ width: `${100 / columns}%` }}
/>
))}
</div>
))}
</div>
</Card>
<tr className="border-border-neutral-secondary border-b last:border-b-0">
{/* Notification dot */}
<td className="px-3 py-4">
<Skeleton className="size-2 rounded-full" />
</td>
{/* Status badge */}
<td className="px-3 py-4">
<Skeleton className="h-6 w-14 rounded-full" />
</td>
{/* Finding title */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-56 rounded" />
</td>
{/* Resource name */}
<td className="px-3 py-4">
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
</td>
{/* Severity badge */}
<td className="px-3 py-4">
<Skeleton className="h-6 w-16 rounded-full" />
</td>
{/* Provider icon */}
<td className="px-3 py-4">
<Skeleton className="size-8 rounded-md" />
</td>
{/* Service */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-16 rounded" />
</td>
{/* Region — flag + name */}
<td className="px-3 py-4">
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-4 w-20 rounded" />
</div>
</td>
{/* Time */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-24 rounded" />
</td>
</tr>
);
};
export const SkeletonTableNewFindings = () => {
const rows = 10;
return (
<div className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden border p-4">
{/* Header: title + description on the left, link on the right */}
<div className="flex w-full items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-64 rounded" />
<Skeleton className="h-3 w-80 rounded" />
</div>
<Skeleton className="h-4 w-40 rounded" />
</div>
{/* Table */}
<table className="w-full">
<thead>
<tr className="border-border-neutral-secondary border-b">
{/* Notification header (no text) */}
<th className="w-8 py-3" />
{/* Status */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Finding */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
{/* Resource name */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-28 rounded" />
</th>
{/* Severity */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
{/* Cloud Provider */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-24 rounded" />
</th>
{/* Service */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Region */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Time */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<SkeletonTableRow key={i} />
))}
</tbody>
</table>
</div>
);
};
@@ -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\)/);
});
});
@@ -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<ScanProps>[] = [
enableSorting: false,
},
{
accessorKey: "started_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Started at" />
),
cell: ({ row }) => {
const {
attributes: { started_at },
} = getScanData(row);
return (
<div className="w-[100px]">
<DateWithTime dateTime={started_at} />
</div>
);
},
enableSorting: false,
},
{
accessorKey: "status",
header: ({ column }) => (
@@ -141,12 +123,22 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
<DataTableColumnHeader column={column} title="Findings" />
),
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 (
<TableLink
href={`/findings?filter[scan__in]=${id}&filter[status__in]=FAIL`}
isDisabled={scanState !== "completed"}
href={`/findings?filter[scan]=${id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL`}
isDisabled={scanState !== "completed" || !scanDate}
label="See Findings"
/>
);
@@ -171,24 +163,10 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
},
enableSorting: false,
},
{
id: "download",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Download" />
),
cell: ({ row }) => {
return (
<div className="mx-auto w-fit">
<DataTableDownloadDetails row={row} />
</div>
);
},
enableSorting: false,
},
{
accessorKey: "resources",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resources" />
<DataTableColumnHeader column={column} title="Impacted Resources" />
),
cell: ({ row }) => {
const {
@@ -202,6 +180,24 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
},
enableSorting: false,
},
{
accessorKey: "started_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Started at" />
),
cell: ({ row }) => {
const {
attributes: { started_at },
} = getScanData(row);
return (
<div className="w-[100px]">
<DateWithTime dateTime={started_at} />
</div>
);
},
enableSorting: false,
},
{
accessorKey: "scheduled_at",
header: ({ column }) => (
@@ -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<ScanProps> {
row: Row<ScanProps>;
}
export function DataTableDownloadDetails<ScanProps>({
row,
}: DataTableDownloadDetailsProps<ScanProps>) {
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 (
<DownloadIconButton
paramId={scanId}
onDownload={handleDownload}
isDownloading={isDownloading}
isDisabled={scanState !== "completed"}
/>
);
}
@@ -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;
+4
View File
@@ -110,6 +110,8 @@ interface DataTableProviderProps<TData, TValue> {
searchBadge?: { label: string; onDismiss: () => void };
/** Optional click handler for top-level rows. */
onRowClick?: (row: Row<TData>) => void;
/** Optional header rendered inside the table container, above the toolbar. */
header?: ReactNode;
}
export function DataTable<TData, TValue>({
@@ -140,6 +142,7 @@ export function DataTable<TData, TValue>({
renderAfterRow,
searchBadge,
onRowClick,
header,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -235,6 +238,7 @@ export function DataTable<TData, TValue>({
isPending && "pointer-events-none opacity-60",
)}
>
{header && <div className="w-full">{header}</div>}
{/* Table Toolbar */}
{showToolbar && (
<div className="flex items-center justify-between">
+37
View File
@@ -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 ──────────────────────────────────────────────────────────
+35
View File
@@ -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");
});
});
+23 -1
View File
@@ -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".
+3 -3
View File
@@ -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",
},
},
],
+11 -7
View File
@@ -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<string, string>): boolean {
}
export function buildFindingScanDateFilters(
scanInsertedAtValues: string[],
scanCompletedAtValues: string[],
): Record<string, string> {
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;
+64
View File
@@ -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> = {}): 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" }),
+25 -2
View File
@@ -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<string, unknown>) =>
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.
+5 -1
View File
@@ -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]"