mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
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:
+8
-4
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>,
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+83
-1
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+12
-2
@@ -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} />
|
||||
|
||||
+5
-9
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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".
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
@@ -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]"
|
||||
|
||||
Reference in New Issue
Block a user