From 577aa14acc5ea30506da77900c2b42b0aab8edd8 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 17 Apr 2026 12:48:57 +0200 Subject: [PATCH] fix(ui): correct IaC findings counters (#10736) Co-authored-by: alejandrobailo --- ui/CHANGELOG.md | 1 + .../findings-by-resource.adapter.test.ts | 31 + .../findings/findings-by-resource.adapter.ts | 11 +- ui/actions/findings/findings.ts | 10 +- .../table/column-finding-groups.test.tsx | 154 ++- .../findings/table/column-finding-groups.tsx | 77 +- .../findings/table/finding-detail-drawer.tsx | 3 +- .../table/findings-group-drill-down.tsx | 24 +- .../findings/table/findings-group-table.tsx | 3 +- .../table/inline-resource-container.tsx | 7 +- .../inline-resource-container.utils.test.ts | 45 + .../table/inline-resource-container.utils.ts | 33 + .../findings/table/notification-indicator.tsx | 13 +- .../resource-detail-drawer-content.test.tsx | 413 ++++++++- .../resource-detail-drawer-content.tsx | 873 +++++++++++------- .../resource-detail-drawer.tsx | 7 + .../use-resource-detail-drawer.test.ts | 491 ++++++++-- .../use-resource-detail-drawer.ts | 150 +-- ui/hooks/use-finding-group-resource-state.ts | 2 +- ui/lib/findings-groups.test.ts | 115 +++ ui/lib/findings-groups.ts | 105 ++- ui/types/components.ts | 1 + 22 files changed, 2012 insertions(+), 557 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 38a74d7dde..be12653904 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 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) --- diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index b8781f0036..87d37c192c 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -115,4 +115,35 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { expect(result[0].id).toBe("finding-1"); expect(result[0].checkId).toBe("s3_check"); }); + + it("should normalize a single finding response into a one-item drawer array", () => { + // Given — getFindingById returns a single JSON:API resource object + const input = { + data: { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "critical", + check_metadata: { + checktitle: "S3 Check", + }, + }, + relationships: { + resources: { data: [] }, + scan: { data: null }, + }, + }, + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0].id).toBe("finding-1"); + expect(result[0].checkTitle).toBe("S3 Check"); + }); }); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 75e8f5a81b..d1685eaa9f 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -165,16 +165,18 @@ type IncludedDict = Record; * then resolves each finding's resource and provider relationships. */ interface JsonApiResponse { - data: FindingApiItem[]; + data: FindingApiItem | FindingApiItem[]; included?: Record[]; } function isJsonApiResponse(value: unknown): value is JsonApiResponse { + const data = (value as { data?: unknown })?.data; + return ( value !== null && typeof value === "object" && "data" in value && - Array.isArray((value as { data: unknown }).data) + (Array.isArray(data) || (data !== null && typeof data === "object")) ); } @@ -188,8 +190,11 @@ export function adaptFindingsByResourceResponse( const resourcesDict = createDict("resources", apiResponse) as IncludedDict; const scansDict = createDict("scans", apiResponse) as IncludedDict; const providersDict = createDict("providers", apiResponse) as IncludedDict; + const findings = Array.isArray(apiResponse.data) + ? apiResponse.data + : [apiResponse.data]; - return apiResponse.data.map((item) => { + return findings.map((item) => { const attrs = item.attributes; const meta = (attrs.check_metadata || {}) as Record; const remediationRaw = meta.remediation as diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 7ce7f931ad..242bd007a8 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -141,7 +141,15 @@ export const getLatestMetadataInfo = async ({ } }; -export const getFindingById = async (findingId: string, include = "") => { +interface GetFindingByIdOptions { + source?: "resource-detail-drawer"; +} + +export const getFindingById = async ( + findingId: string, + include = "", + _options?: GetFindingByIdOptions, +) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/findings/${findingId}`); diff --git a/ui/components/findings/table/column-finding-groups.test.tsx b/ui/components/findings/table/column-finding-groups.test.tsx index 3f2568db51..eefbcb5a45 100644 --- a/ui/components/findings/table/column-finding-groups.test.tsx +++ b/ui/components/findings/table/column-finding-groups.test.tsx @@ -78,6 +78,18 @@ vi.mock("./notification-indicator", () => ({ }, })); +vi.mock("@/components/shadcn/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("./provider-icon-cell", () => ({ + ProviderIconCell: ({ provider }: { provider: string }) => ( + {provider} + ), +})); + // --------------------------------------------------------------------------- // Import after mocks // --------------------------------------------------------------------------- @@ -148,6 +160,26 @@ function renderFindingCell( render(
{CellComponent({ row: { original: group } })}
); } +function renderFindingGroupTitleCell(overrides?: Partial) { + const columns = getColumnFindingGroups({ + rowSelection: {}, + selectableRowCount: 1, + onDrillDown: vi.fn(), + }); + + const findingColumn = columns.find( + (col) => (col as { accessorKey?: string }).accessorKey === "finding", + ); + if (!findingColumn?.cell) throw new Error("finding column not found"); + + const group = makeGroup(overrides); + const CellComponent = findingColumn.cell as (props: { + row: { original: FindingGroupRow }; + }) => ReactNode; + + render(
{CellComponent({ row: { original: group } })}
); +} + function renderImpactedResourcesCell(overrides?: Partial) { const columns = getColumnFindingGroups({ rowSelection: {}, @@ -171,11 +203,13 @@ function renderImpactedResourcesCell(overrides?: Partial) { } function renderSelectCell(overrides?: Partial) { + const onDrillDown = + vi.fn<(checkId: string, group: FindingGroupRow) => void>(); const toggleSelected = vi.fn(); const columns = getColumnFindingGroups({ rowSelection: {}, selectableRowCount: 1, - onDrillDown: vi.fn(), + onDrillDown, }); const selectColumn = columns.find( @@ -206,7 +240,7 @@ function renderSelectCell(overrides?: Partial) { , ); - return { toggleSelected }; + return { onDrillDown, toggleSelected }; } // --------------------------------------------------------------------------- @@ -231,6 +265,15 @@ describe("column-finding-groups — accessibility of check title cell", () => { expect(impactedProvidersColumn).toBeUndefined(); }); + it("should render the first provider icon with its provider name", () => { + // Given + renderFindingGroupTitleCell({ providers: ["iac"] }); + + // Then + expect(screen.getByTestId("provider-icon-iac")).toBeInTheDocument(); + expect(screen.getByText("Infrastructure as Code")).toBeInTheDocument(); + }); + it("should render the check title as a button element (not a

)", () => { // Given const onDrillDown = @@ -332,6 +375,47 @@ describe("column-finding-groups — accessibility of check title cell", () => { }), ); }); + + it("should keep zero-resource fallback groups non-clickable even when fallback counts are present", () => { + // Given + const onDrillDown = + vi.fn<(checkId: string, group: FindingGroupRow) => void>(); + + renderFindingCell("Fallback IaC Check", onDrillDown, { + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 2, + manualCount: 1, + }); + + // Then + expect( + screen.queryByRole("button", { name: "Fallback IaC Check" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Fallback IaC Check")).toBeInTheDocument(); + expect(onDrillDown).not.toHaveBeenCalled(); + }); + + it("should keep fallback groups non-clickable when the displayed total is zero", () => { + // Given + const onDrillDown = + vi.fn<(checkId: string, group: FindingGroupRow) => void>(); + + // When + renderFindingCell("No failing findings", onDrillDown, { + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 0, + }); + + // Then + expect( + screen.queryByRole("button", { name: "No failing findings" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("No failing findings")).toBeInTheDocument(); + }); }); describe("column-finding-groups — impacted resources count", () => { @@ -345,6 +429,36 @@ describe("column-finding-groups — impacted resources count", () => { // Then expect(screen.getByText("3/5")).toBeInTheDocument(); }); + + it("should fall back to finding counts when resources total is zero", () => { + // Given/When + renderImpactedResourcesCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + muted: false, + }); + + // Then + expect(screen.getByText("3/5")).toBeInTheDocument(); + }); + + it("should include muted findings in the denominator when the row is muted", () => { + // Given/When + renderImpactedResourcesCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + failMutedCount: 4, + passMutedCount: 1, + muted: true, + }); + + // Then + expect(screen.getByText("3/10")).toBeInTheDocument(); + }); }); describe("column-finding-groups — group selection", () => { @@ -357,6 +471,42 @@ describe("column-finding-groups — group selection", () => { expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled(); }); + + it("should hide the chevron for zero-resource fallback groups even when fallback counts are present", () => { + // Given + const { onDrillDown } = renderSelectCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 2, + manualCount: 1, + }); + + // Then + expect( + screen.queryByRole("button", { + name: "Expand S3 Bucket Public Access", + }), + ).not.toBeInTheDocument(); + expect(onDrillDown).not.toHaveBeenCalled(); + }); + + it("should hide the chevron for zero-resource groups when the displayed total is zero", () => { + // Given/When + renderSelectCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 0, + }); + + // Then + expect( + screen.queryByRole("button", { + name: "Expand S3 Bucket Public Access", + }), + ).not.toBeInTheDocument(); + }); }); describe("column-finding-groups — indicators", () => { diff --git a/ui/components/findings/table/column-finding-groups.tsx b/ui/components/findings/table/column-finding-groups.tsx index ffd6f18845..79b584edcc 100644 --- a/ui/components/findings/table/column-finding-groups.tsx +++ b/ui/components/findings/table/column-finding-groups.tsx @@ -4,6 +4,11 @@ import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; import { ChevronRight } from "lucide-react"; import { Checkbox } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { DataTableColumnHeader, SeverityBadge, @@ -11,15 +16,19 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib"; import { + canDrillDownFindingGroup, getFilteredFindingGroupDelta, + getFindingGroupImpactedCounts, isFindingGroupMuted, } from "@/lib/findings-groups"; import { FindingGroupRow } from "@/types"; +import { getProviderDisplayName } from "@/types/providers"; import { DataTableRowActions } from "./data-table-row-actions"; import { canMuteFindingGroup } from "./finding-group-selection"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; -import { DeltaValues, NotificationIndicator } from "./notification-indicator"; +import { NotificationIndicator } from "./notification-indicator"; +import { ProviderIconCell } from "./provider-icon-cell"; interface GetColumnFindingGroupsOptions { rowSelection: RowSelectionState; @@ -83,14 +92,7 @@ export function getColumnFindingGroups({ const allMuted = isFindingGroupMuted(group); const isExpanded = expandedCheckId === group.checkId; const deltaKey = getFilteredFindingGroupDelta(group, filters); - const delta = - deltaKey === "new" - ? DeltaValues.NEW - : deltaKey === "changed" - ? DeltaValues.CHANGED - : DeltaValues.NONE; - - const canExpand = group.resourcesTotal > 0; + const canExpand = canDrillDownFindingGroup(group); const canSelect = canMuteFindingGroup({ resourcesFail: group.resourcesFail, resourcesTotal: group.resourcesTotal, @@ -101,7 +103,7 @@ export function getColumnFindingGroups({ return (

@@ -175,23 +177,43 @@ export function getColumnFindingGroups({ ), cell: ({ row }) => { const group = row.original; - const canExpand = group.resourcesTotal > 0; + const canExpand = canDrillDownFindingGroup(group); + const provider = group.providers[0]; + const providerName = provider + ? getProviderDisplayName(provider) + : undefined; return ( -
- {canExpand ? ( - - ) : ( - - {group.checkTitle} - - )} +
+ {provider && providerName ? ( + + +
+ +
+
+ {providerName} +
+ ) : null} +
+ {canExpand ? ( + + ) : ( + + {group.checkTitle} + + )} +
); }, @@ -216,10 +238,11 @@ export function getColumnFindingGroups({ ), cell: ({ row }) => { const group = row.original; + const counts = getFindingGroupImpactedCounts(group); return ( ); }, diff --git a/ui/components/findings/table/finding-detail-drawer.tsx b/ui/components/findings/table/finding-detail-drawer.tsx index 5281686d20..93f8dd0438 100644 --- a/ui/components/findings/table/finding-detail-drawer.tsx +++ b/ui/components/findings/table/finding-detail-drawer.tsx @@ -30,7 +30,6 @@ export function FindingDetailDrawer({ }: FindingDetailDrawerProps) { const drawer = useResourceDetailDrawer({ resources: [findingToFindingResourceRow(finding)], - checkId: finding.attributes.check_id, totalResourceCount: 1, initialIndex: defaultOpen || inline ? 0 : null, }); @@ -63,6 +62,7 @@ export function FindingDetailDrawer({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} onNavigatePrev={drawer.navigatePrev} @@ -87,6 +87,7 @@ export function FindingDetailDrawer({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} onNavigatePrev={drawer.navigatePrev} diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 445adb6ec7..2bee7d5667 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -22,6 +22,7 @@ import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource import { cn, hasHistoricalFindingFilter } from "@/lib"; import { getFilteredFindingGroupDelta, + getFindingGroupImpactedCounts, isFindingGroupMuted, } from "@/lib/findings-groups"; import { FindingGroupRow } from "@/types"; @@ -30,7 +31,8 @@ import { FloatingMuteButton } from "../floating-mute-button"; import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; -import { DeltaValues, NotificationIndicator } from "./notification-indicator"; +import { getFindingGroupEmptyStateMessage } from "./inline-resource-container.utils"; +import { NotificationIndicator } from "./notification-indicator"; import { ResourceDetailDrawer } from "./resource-detail-drawer"; interface FindingsGroupDrillDownProps { @@ -96,14 +98,8 @@ export function FindingsGroupDrillDown({ // Delta for the sticky header const deltaKey = getFilteredFindingGroupDelta(group, filters); - const delta = - deltaKey === "new" - ? DeltaValues.NEW - : deltaKey === "changed" - ? DeltaValues.CHANGED - : DeltaValues.NONE; - const allMuted = isFindingGroupMuted(group); + const impactedCounts = getFindingGroupImpactedCounts(group); const rows = table.getRowModel().rows; @@ -139,7 +135,7 @@ export function FindingsGroupDrillDown({ {/* Notification indicator */} @@ -159,8 +155,8 @@ export function FindingsGroupDrillDown({ {/* Impacted resources count */}
@@ -209,9 +205,7 @@ export function FindingsGroupDrillDown({ colSpan={columns.length} className="h-24 text-center" > - {Object.keys(filters).length > 0 - ? "No resources found for the selected filters." - : "No resources found."} + {getFindingGroupEmptyStateMessage(group, filters)} ) : null} @@ -248,8 +242,10 @@ export function FindingsGroupDrillDown({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} + showSyntheticResourceHint={group.resourcesTotal === 0} onNavigatePrev={drawer.navigatePrev} onNavigateNext={drawer.navigateNext} onMuteComplete={handleDrawerMuteComplete} diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 2f3a515415..1459972e22 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -6,6 +6,7 @@ import { useRef, useState } from "react"; import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; import { DataTable } from "@/components/ui/table"; +import { canDrillDownFindingGroup } from "@/lib/findings-groups"; import { FindingGroupRow, MetaDataProps } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; @@ -140,7 +141,7 @@ export function FindingsGroupTable({ const handleDrillDown = (checkId: string, group: FindingGroupRow) => { // No resources in the group → nothing to show, skip drill-down - if (group.resourcesTotal === 0) return; + if (!canDrillDownFindingGroup(group)) return; // Toggle: same group = collapse, different = switch if (expandedCheckId === checkId) { diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index 47e6d0eb06..2aa13056d9 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -20,6 +20,7 @@ import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { getFilteredFindingGroupResourceCount, + getFindingGroupEmptyStateMessage, getFindingGroupSkeletonCount, } from "./inline-resource-container.utils"; import { ResourceDetailDrawer } from "./resource-detail-drawer"; @@ -278,9 +279,7 @@ export function InlineResourceContainer({ colSpan={columns.length} className="h-24 text-center" > - {Object.keys(filters).length > 0 - ? "No resources found for the selected filters." - : "No resources found."} + {getFindingGroupEmptyStateMessage(group, filters)} )} @@ -334,8 +333,10 @@ export function InlineResourceContainer({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} + showSyntheticResourceHint={group.resourcesTotal === 0} onNavigatePrev={drawer.navigatePrev} onNavigateNext={drawer.navigateNext} onMuteComplete={handleDrawerMuteComplete} diff --git a/ui/components/findings/table/inline-resource-container.utils.test.ts b/ui/components/findings/table/inline-resource-container.utils.test.ts index 8fac280669..ac1f968521 100644 --- a/ui/components/findings/table/inline-resource-container.utils.test.ts +++ b/ui/components/findings/table/inline-resource-container.utils.test.ts @@ -4,6 +4,7 @@ import type { FindingGroupRow } from "@/types"; import { getFilteredFindingGroupResourceCount, + getFindingGroupEmptyStateMessage, getFindingGroupSkeletonCount, isFailOnlyStatusFilter, } from "./inline-resource-container.utils"; @@ -99,3 +100,47 @@ describe("getFindingGroupSkeletonCount", () => { ).toBe(1); }); }); + +describe("getFindingGroupEmptyStateMessage", () => { + it("returns the muted hint when muted findings are excluded and no visible resources remain", () => { + expect( + getFindingGroupEmptyStateMessage( + makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + mutedCount: 1, + failCount: 0, + passCount: 0, + }), + { + "filter[status]": "FAIL", + "filter[muted]": "false", + }, + ), + ).toBe( + "No resources match the current filters. Try enabling Include muted to view muted findings.", + ); + }); + + it("keeps the generic filtered empty state when muted findings are already included", () => { + expect( + getFindingGroupEmptyStateMessage( + makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + mutedCount: 1, + }), + { + "filter[status]": "FAIL", + "filter[muted]": "include", + }, + ), + ).toBe("No resources found for the selected filters."); + }); + + it("keeps the generic empty state when no filters are active", () => { + expect(getFindingGroupEmptyStateMessage(makeGroup(), {})).toBe( + "No resources found.", + ); + }); +}); diff --git a/ui/components/findings/table/inline-resource-container.utils.ts b/ui/components/findings/table/inline-resource-container.utils.ts index 9967ea7da3..274304e03f 100644 --- a/ui/components/findings/table/inline-resource-container.utils.ts +++ b/ui/components/findings/table/inline-resource-container.utils.ts @@ -33,6 +33,18 @@ export function isFailOnlyStatusFilter( return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL"; } +function includesMutedFindings( + filters: Record, +): boolean { + const mutedFilter = filters["filter[muted]"]; + + if (Array.isArray(mutedFilter)) { + return mutedFilter.includes("include"); + } + + return mutedFilter === "include"; +} + export function getFilteredFindingGroupResourceCount( group: FindingGroupRow, filters: Record, @@ -53,3 +65,24 @@ export function getFindingGroupSkeletonCount( // empty state ("No resources found") replaces the skeleton. return Math.max(1, Math.min(filteredTotal, maxSkeletonRows)); } + +export function getFindingGroupEmptyStateMessage( + group: FindingGroupRow, + filters: Record, +): string { + const hasFilters = Object.keys(filters).length > 0; + + if (!hasFilters) { + return "No resources found."; + } + + const mutedExcluded = !includesMutedFindings(filters); + const hasMutedFindings = (group.mutedCount ?? 0) > 0; + const visibleCount = getFilteredFindingGroupResourceCount(group, filters); + + if (mutedExcluded && hasMutedFindings && visibleCount === 0) { + return "No resources match the current filters. Try enabling Include muted to view muted findings."; + } + + return "No resources found for the selected filters."; +} diff --git a/ui/components/findings/table/notification-indicator.tsx b/ui/components/findings/table/notification-indicator.tsx index 324e216c57..be50ee1515 100644 --- a/ui/components/findings/table/notification-indicator.tsx +++ b/ui/components/findings/table/notification-indicator.tsx @@ -17,14 +17,11 @@ import { } from "@/components/shadcn/tooltip"; import { DOCS_URLS } from "@/lib/external-urls"; import { cn } from "@/lib/utils"; +import { FINDING_DELTA, type FindingDelta } from "@/types"; -export const DeltaValues = { - NEW: "new", - CHANGED: "changed", - NONE: "none", -} as const; +export const DeltaValues = FINDING_DELTA; -export type DeltaType = (typeof DeltaValues)[keyof typeof DeltaValues]; +export type DeltaType = Exclude; interface NotificationIndicatorProps { delta?: DeltaType; @@ -124,12 +121,12 @@ function MutedIndicator({ mutedReason }: { mutedReason?: string }) { ({ })); vi.mock("@/components/shadcn/skeleton/skeleton", () => ({ - Skeleton: () =>
, + Skeleton: ({ + className, + ...props + }: HTMLAttributes & { className?: string }) => ( +
+ ), })); vi.mock("@/components/shadcn/spinner/spinner", () => ({ @@ -309,6 +314,7 @@ vi.mock("../../muted", () => ({ // --------------------------------------------------------------------------- import type { ResourceDrawerFinding } from "@/actions/findings"; +import type { FindingResourceRow } from "@/types"; import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content"; import type { CheckMeta } from "./use-resource-detail-drawer"; @@ -374,6 +380,29 @@ const mockFinding: ResourceDrawerFinding = { scan: null, }; +const mockResourceRow: FindingResourceRow = { + id: "row-1", + rowType: "resource", + findingId: "finding-1", + checkId: "s3_check", + providerType: "aws", + providerAlias: "prod", + providerUid: "123456789", + resourceName: "my-bucket", + resourceType: "Bucket", + resourceGroup: "default", + resourceUid: "arn:aws:s3:::bucket", + service: "s3", + region: "us-east-1", + severity: "critical", + status: "FAIL", + delta: null, + isMuted: false, + mutedReason: undefined, + firstSeenAt: null, + lastSeenAt: null, +}; + // --------------------------------------------------------------------------- // Fix 1: Lighthouse AI button text change // --------------------------------------------------------------------------- @@ -937,3 +966,385 @@ describe("ResourceDetailDrawerContent — other findings mute refresh", () => { expect(onMuteComplete).not.toHaveBeenCalled(); }); }); + +describe("ResourceDetailDrawerContent — synthetic resource empty state", () => { + it("should explain that simulated IaC resources never have other findings", () => { + // Given/When + render( + , + ); + + // Then + expect( + screen.getByText( + "No other findings are available for this IaC resource.", + ), + ).toBeInTheDocument(); + }); +}); + +describe("ResourceDetailDrawerContent — current resource row display", () => { + it("should render resource card fields from the current resource row instead of the fetched finding", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + providerAlias: "row-account", + providerUid: "row-provider-uid", + resourceName: "row-resource-name", + resourceUid: "row-resource-uid", + service: "row-service", + region: "eu-west-1", + resourceType: "row-type", + resourceGroup: "row-group", + severity: "low", + status: "PASS", + }; + const fetchedFinding: ResourceDrawerFinding = { + ...mockFinding, + providerAlias: "finding-account", + providerUid: "finding-provider-uid", + resourceName: "finding-resource-name", + resourceUid: "finding-resource-uid", + resourceService: "finding-service", + resourceRegion: "ap-south-1", + resourceType: "finding-type", + resourceGroup: "finding-group", + severity: "critical", + status: "FAIL", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByText("row-service")).toBeInTheDocument(); + expect(screen.getByText("eu-west-1")).toBeInTheDocument(); + expect(screen.getByText("row-group")).toBeInTheDocument(); + expect(screen.getByText("row-type")).toBeInTheDocument(); + expect(screen.getByText("FAIL")).toBeInTheDocument(); + expect(screen.getByText("critical")).toBeInTheDocument(); + expect(screen.queryByText("finding-service")).not.toBeInTheDocument(); + expect(screen.queryByText("ap-south-1")).not.toBeInTheDocument(); + expect(screen.queryByText("finding-group")).not.toBeInTheDocument(); + expect(screen.queryByText("finding-type")).not.toBeInTheDocument(); + }); + + it("should prefer the fetched finding status and severity in the header when the current row is stale", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + severity: "critical", + status: "FAIL", + isMuted: false, + }; + const fetchedFinding: ResourceDrawerFinding = { + ...mockFinding, + severity: "low", + status: "PASS", + isMuted: true, + mutedReason: "Muted after refresh", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByText("PASS")).toBeInTheDocument(); + expect(screen.getByText("low")).toBeInTheDocument(); + expect(screen.queryByText("FAIL")).not.toBeInTheDocument(); + expect(screen.queryByText("critical")).not.toBeInTheDocument(); + }); +}); + +describe("ResourceDetailDrawerContent — header skeleton while navigating", () => { + it("should keep row-backed navigation chrome visible while hiding stale finding details during carousel navigation", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + checkId: mockCheckMeta.checkId, + resourceName: "next-bucket", + resourceUid: "next-resource-uid", + service: "ec2", + region: "eu-west-1", + resourceType: "Instance", + resourceGroup: "row-group", + severity: "low", + status: "PASS", + findingId: "finding-2", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByText("PASS")).toBeInTheDocument(); + expect(screen.getByText("low")).toBeInTheDocument(); + expect(screen.getByText("ec2")).toBeInTheDocument(); + expect(screen.getByText("eu-west-1")).toBeInTheDocument(); + expect(screen.getByText("row-group")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Finding Overview" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Other Findings For This Resource" }), + ).toBeInTheDocument(); + expect(screen.queryByText("uid-1")).not.toBeInTheDocument(); + expect(screen.queryByText("Status extended")).not.toBeInTheDocument(); + expect(screen.queryByText("FAIL")).not.toBeInTheDocument(); + expect(screen.queryByText("critical")).not.toBeInTheDocument(); + }); + + it("should skeletonize stale check-level header content when navigating to a different check", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + checkId: "ec2_check", + findingId: "finding-2", + severity: "low", + status: "PASS", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByTestId("drawer-header-skeleton")).toBeInTheDocument(); + expect(screen.queryByText("S3 Check")).not.toBeInTheDocument(); + expect(screen.queryByText("PCI-DSS")).not.toBeInTheDocument(); + expect(screen.getByText("PASS")).toBeInTheDocument(); + expect(screen.getByText("low")).toBeInTheDocument(); + }); + + it("should keep same-check overview sections visible while hiding stale finding-specific details during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByText("Risk:")).toBeInTheDocument(); + expect(screen.getByText("Description:")).toBeInTheDocument(); + expect(screen.getByText("Remediation:")).toBeInTheDocument(); + expect(screen.getByText("security")).toBeInTheDocument(); + expect(screen.queryByText("Status Extended:")).not.toBeInTheDocument(); + expect(screen.queryByText("uid-1")).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { + name: "Analyze This Finding With Lighthouse AI", + }), + ).not.toBeInTheDocument(); + }); + + it("should keep the overview tab shell visible with section skeletons when navigating to a different check", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + checkId: "ec2_check", + findingId: "finding-2", + severity: "low", + status: "PASS", + }; + + // When + render( + , + ); + + // Then + expect( + screen.getByTestId("overview-navigation-skeleton"), + ).toBeInTheDocument(); + expect(screen.queryByText("Risk:")).not.toBeInTheDocument(); + expect(screen.queryByText("Description:")).not.toBeInTheDocument(); + expect(screen.queryByText("Remediation:")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Finding Overview" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Other Findings For This Resource" }), + ).toBeInTheDocument(); + }); + + it("should keep other findings table headers visible while skeletonizing only the rows during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByText("Status")).toBeInTheDocument(); + expect(screen.getByText("Finding")).toBeInTheDocument(); + expect(screen.getByText("Severity")).toBeInTheDocument(); + expect(screen.getByText("Time")).toBeInTheDocument(); + expect( + screen.getByTestId("other-findings-total-entries-skeleton"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("other-findings-navigation-skeleton"), + ).toBeInTheDocument(); + }); + + it("should keep scans labels visible while skeletonizing only the scan values during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect( + screen.getByText("Showing the latest scan that evaluated this finding"), + ).toBeInTheDocument(); + expect(screen.getByText("Scan Name")).toBeInTheDocument(); + expect(screen.getByText("Resources Scanned")).toBeInTheDocument(); + expect(screen.getByText("Progress")).toBeInTheDocument(); + expect(screen.getByText("Trigger")).toBeInTheDocument(); + expect(screen.getByText("State")).toBeInTheDocument(); + expect(screen.getByText("Duration")).toBeInTheDocument(); + expect(screen.getByText("Started At")).toBeInTheDocument(); + expect(screen.getByText("Completed At")).toBeInTheDocument(); + expect(screen.getByText("Launched At")).toBeInTheDocument(); + expect(screen.getByText("Scheduled At")).toBeInTheDocument(); + expect(screen.getByTestId("scans-navigation-skeleton")).toBeInTheDocument(); + }); + + it("should keep the events tab shell visible while showing timeline row skeletons during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByRole("button", { name: "Events" })).toBeInTheDocument(); + expect( + screen.getByTestId("events-navigation-skeleton"), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index d488462b14..626f33bfbd 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -70,6 +70,7 @@ import { getFailingForLabel } from "@/lib/date-utils"; import { formatDuration } from "@/lib/date-utils"; import { getRegionFlag } from "@/lib/region-flags"; import type { ComplianceOverviewData } from "@/types/compliance"; +import type { FindingResourceRow } from "@/types/findings-table"; import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; @@ -303,8 +304,10 @@ interface ResourceDetailDrawerContentProps { checkMeta: CheckMeta | null; currentIndex: number; totalResources: number; + currentResource?: FindingResourceRow | null; currentFinding: ResourceDrawerFinding | null; otherFindings: ResourceDrawerFinding[]; + showSyntheticResourceHint?: boolean; onNavigatePrev: () => void; onNavigateNext: () => void; onMuteComplete: () => void; @@ -316,8 +319,10 @@ export function ResourceDetailDrawerContent({ checkMeta, currentIndex, totalResources, + currentResource = null, currentFinding, otherFindings, + showSyntheticResourceHint = false, onNavigatePrev, onNavigateNext, onMuteComplete, @@ -371,8 +376,30 @@ export function ResourceDetailDrawerContent({ } // checkMeta is always available from here. - // currentFinding may be null during resource loading (e.g. drawer reopen). - const f = currentFinding; + // During carousel navigation we only trust row-backed data until the next + // finding payload is fully ready, otherwise stale details flash briefly. + const f = isNavigating ? null : currentFinding; + const isCheckMetaFresh = + !currentResource?.checkId || currentResource.checkId === checkMeta.checkId; + const showCheckMetaContent = !isNavigating || isCheckMetaFresh; + const findingStatus = f?.status ?? currentResource?.status; + const findingSeverity = f?.severity ?? currentResource?.severity; + const findingDelta = f?.delta ?? currentResource?.delta; + const findingIsMuted = f?.isMuted ?? currentResource?.isMuted; + const findingMutedReason = + f?.mutedReason ?? currentResource?.mutedReason ?? "This finding is muted"; + const providerType = currentResource?.providerType ?? f?.providerType; + const providerAlias = currentResource?.providerAlias ?? f?.providerAlias; + const providerUid = currentResource?.providerUid ?? f?.providerUid; + const resourceName = currentResource?.resourceName ?? f?.resourceName; + const resourceUid = currentResource?.resourceUid ?? f?.resourceUid; + const resourceService = currentResource?.service ?? f?.resourceService; + const resourceRegion = currentResource?.region ?? f?.resourceRegion; + const resourceGroup = currentResource?.resourceGroup ?? f?.resourceGroup; + const resourceType = currentResource?.resourceType ?? f?.resourceType; + const resourceRegionLabel = resourceRegion || "-"; + const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null; + const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null; const hasPrev = currentIndex > 0; const hasNext = currentIndex < totalResources - 1; const selectedScanIds = parseSelectedScanIds( @@ -385,7 +412,9 @@ export function ResourceDetailDrawerContent({ ? (f?.scan?.id ?? null) : null; const regionFilter = searchParams.get("filter[region__in]"); - const nativeIacConfig = resolveNativeIacConfig(f?.providerType); + const nativeIacConfig = resolveNativeIacConfig(providerType); + const showOverviewCheckMetaContent = showCheckMetaContent; + const showOverviewFindingContent = Boolean(f); const handleOpenCompliance = async (framework: string) => { if (!complianceScanId || resolvingFramework) { @@ -448,104 +477,130 @@ export function ResourceDetailDrawerContent({ /> )} - {/* Header: status badges + title (check-level from checkMeta) */} + {/* Header: keep row-backed badges visible; only hide stale check metadata */}
- {f && } - {f && } - {f?.delta && ( + {findingStatus && ( + + )} + {findingSeverity && } + {findingDelta && (
- + - {f.delta} + {findingDelta}
)} - {f && ( - + {findingIsMuted !== undefined && ( + )}
-

- {checkMeta.checkTitle} -

+ {showCheckMetaContent ? ( + <> +

+ {checkMeta.checkTitle} +

- {checkMeta.complianceFrameworks.length > 0 && ( -
- - Compliance Frameworks: - -
- {checkMeta.complianceFrameworks.map((framework) => { - const icon = getComplianceIcon(framework); - const isNavigable = Boolean(complianceScanId); - const isResolving = resolvingFramework === framework; + {checkMeta.complianceFrameworks.length > 0 && ( +
+ + Compliance Frameworks: + +
+ {checkMeta.complianceFrameworks.map((framework) => { + const icon = getComplianceIcon(framework); + const isNavigable = Boolean(complianceScanId); + const isResolving = resolvingFramework === framework; - return icon ? ( - - - {isNavigable ? ( - + ) : ( +
+ {framework} +
)} - - ) : ( -
- {framework} -
- )} -
- {framework} -
- ) : ( - - - {isNavigable ? ( - + ) : ( + + {framework} + )} - - ) : ( - - {framework} - - )} - - {framework} - - ); - })} + + {framework} + + ); + })} +
+
+ )} + + ) : ( + )} @@ -584,7 +639,7 @@ export function ResourceDetailDrawerContent({ {/* Resource card */}
{/* Resource info — shows loading when currentFinding is not yet available */} - {!f || isNavigating ? ( + {!currentResource && !f ? ( ) : ( <> @@ -593,95 +648,110 @@ export function ResourceDetailDrawerContent({
{/* Row 1: Account, Resource, Service, Region */} } - entityAlias={f.providerAlias} - entityId={f.providerUid} + entityAlias={providerAlias} + entityId={providerUid} /> } - entityAlias={f.resourceName} - entityId={f.resourceUid} + entityAlias={resourceName} + entityId={resourceUid} idLabel="UID" /> - {f.resourceService} + {resourceService} - {getRegionFlag(f.resourceRegion) && ( + {getRegionFlag(resourceRegionLabel) && ( - {getRegionFlag(f.resourceRegion)} + {getRegionFlag(resourceRegionLabel)} )} - {f.resourceRegion} + {resourceRegionLabel} {/* Row 2: Dates */} - + - + - {getFailingForLabel(f.firstSeenAt) || "-"} + {getFailingForLabel(firstSeenAt) || "-"} - {f.resourceGroup || "-"} + {resourceGroup || "-"} {/* Row 3: IDs */} - + {currentResource?.findingId || f?.id ? ( + + ) : ( + + )} - + {f?.uid ? ( + + ) : ( + + )} {/* Row 4: Resource metadata */} - {f.resourceType || "-"} + {resourceType || "-"}
{/* Actions button — fixed size, aligned with row 1 */}
- - - ) : ( - - ) - } - label={f.isMuted ? "Muted" : "Mute"} - disabled={f.isMuted} - onSelect={() => setIsMuteModalOpen(true)} - /> - } - label="Send to Jira" - onSelect={() => setIsJiraModalOpen(true)} - /> - + {f ? ( + + + ) : ( + + ) + } + label={f.isMuted ? "Muted" : "Mute"} + disabled={f.isMuted} + onSelect={() => setIsMuteModalOpen(true)} + /> + } + label="Send to Jira" + onSelect={() => setIsJiraModalOpen(true)} + /> + + ) : ( + + )}
@@ -708,162 +778,170 @@ export function ResourceDetailDrawerContent({ value="overview" className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto" > - {/* Card 1: Risk + Description + Status Extended */} - {(checkMeta.risk || checkMeta.description || f?.statusExtended) && ( - - {checkMeta.risk && ( - - - Risk: - - {checkMeta.risk} - - )} - {checkMeta.description && ( -
- - Description: - - - {checkMeta.description} - -
- )} - {f?.statusExtended && ( -
- - Status Extended: - -

- {f.statusExtended} -

-
- )} -
- )} - - {/* Card 2: Remediation + Commands */} - {(checkMeta.remediation.recommendation.text || - checkMeta.remediation.code.cli || - checkMeta.remediation.code.terraform || - checkMeta.remediation.code.nativeiac) && ( - - {checkMeta.remediation.recommendation.text && ( -
- - Remediation: - -
-
+ {showOverviewCheckMetaContent ? ( + <> + {/* Card 1: Risk + Description + Status Extended */} + {(checkMeta.risk || + checkMeta.description || + showOverviewFindingContent) && ( + + {checkMeta.risk && ( + + + Risk: + + {checkMeta.risk} + + )} + {checkMeta.description && ( +
+ + Description: + - {checkMeta.remediation.recommendation.text} + {checkMeta.description}
- {checkMeta.remediation.recommendation.url && ( - - View in Prowler Hub - - )} + )} + {showOverviewFindingContent && f?.statusExtended && ( +
+ + Status Extended: + +

+ {f.statusExtended} +

+
+ )} +
+ )} + + {/* Card 2: Remediation + Commands */} + {(checkMeta.remediation.recommendation.text || + checkMeta.remediation.code.cli || + checkMeta.remediation.code.terraform || + checkMeta.remediation.code.nativeiac) && ( + + {checkMeta.remediation.recommendation.text && ( +
+ + Remediation: + +
+
+ + {checkMeta.remediation.recommendation.text} + +
+ {checkMeta.remediation.recommendation.url && ( + + View in Prowler Hub + + )} +
+
+ )} + + {checkMeta.remediation.code.cli && ( +
+ {renderRemediationCodeBlock({ + label: "CLI Command", + language: QUERY_EDITOR_LANGUAGE.SHELL, + value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`, + copyValue: stripCodeFences( + checkMeta.remediation.code.cli, + ), + })} +
+ )} + + {checkMeta.remediation.code.terraform && ( +
+ {renderRemediationCodeBlock({ + label: "Terraform", + language: QUERY_EDITOR_LANGUAGE.HCL, + value: stripCodeFences( + checkMeta.remediation.code.terraform, + ), + })} +
+ )} + + {checkMeta.remediation.code.nativeiac && providerType && ( +
+ {renderRemediationCodeBlock({ + label: nativeIacConfig.label, + language: nativeIacConfig.language, + value: stripCodeFences( + checkMeta.remediation.code.nativeiac, + ), + })} +
+ )} + + {checkMeta.remediation.code.other && ( +
+ + Remediation Steps: + + + {checkMeta.remediation.code.other} + +
+ )} +
+ )} + + {checkMeta.additionalUrls.length > 0 && ( + +
+ + References: + +
    + {checkMeta.additionalUrls.map((link, idx) => ( +
  • + + {link} + +
  • + ))} +
-
+ )} - {checkMeta.remediation.code.cli && ( -
- {renderRemediationCodeBlock({ - label: "CLI Command", - language: QUERY_EDITOR_LANGUAGE.SHELL, - value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`, - copyValue: stripCodeFences( - checkMeta.remediation.code.cli, - ), - })} -
+ {checkMeta.categories.length > 0 && ( + +
+ + Categories: + +
+ {checkMeta.categories.map((category) => ( + + {category} + + ))} +
+
+
)} - - {checkMeta.remediation.code.terraform && ( -
- {renderRemediationCodeBlock({ - label: "Terraform", - language: QUERY_EDITOR_LANGUAGE.HCL, - value: stripCodeFences( - checkMeta.remediation.code.terraform, - ), - })} -
- )} - - {checkMeta.remediation.code.nativeiac && f && ( -
- {renderRemediationCodeBlock({ - label: nativeIacConfig.label, - language: nativeIacConfig.language, - value: stripCodeFences( - checkMeta.remediation.code.nativeiac, - ), - })} -
- )} - - {checkMeta.remediation.code.other && ( -
- - Remediation Steps: - - - {checkMeta.remediation.code.other} - -
- )} - - )} - - {checkMeta.additionalUrls.length > 0 && ( - -
- - References: - -
    - {checkMeta.additionalUrls.map((link, idx) => ( -
  • - - {link} - -
  • - ))} -
-
-
- )} - - {checkMeta.categories.length > 0 && ( - -
- - Categories: - -
- {checkMeta.categories.map((category) => ( - - {category} - - ))} -
-
-
+ + ) : ( + )} @@ -872,14 +950,21 @@ export function ResourceDetailDrawerContent({ value="other-findings" className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto" > - {!f || isNavigating ? ( + {!f && !isNavigating ? ( ) : ( <>
- - {otherFindings.length} Total Entries - + {isNavigating ? ( + + ) : ( + + {otherFindings.length} Total Entries + + )}
@@ -910,7 +995,9 @@ export function ResourceDetailDrawerContent({ - {otherFindings.length > 0 ? ( + {isNavigating ? ( + + ) : otherFindings.length > 0 ? ( otherFindings.map((finding) => ( - No other findings for this resource. + {showSyntheticResourceHint + ? "No other findings are available for this IaC resource." + : "No other findings for this resource."} @@ -942,7 +1031,13 @@ export function ResourceDetailDrawerContent({ {/* Scans Tab */} - {f?.scan ? ( + {!f && !isNavigating ? ( +

+ Scan information is not available. +

+ ) : isNavigating ? ( + + ) : ( <>

@@ -950,7 +1045,7 @@ export function ResourceDetailDrawerContent({

- {f.scan.name || "N/A"} + {f?.scan?.name || "N/A"} - {f.scan.uniqueResourceCount} + {f?.scan?.uniqueResourceCount} - {f.scan.progress}% + {f?.scan?.progress}%
- {f.scan.trigger} + {f?.scan?.trigger} - {f.scan.state} + {f?.scan?.state} - {formatDuration(f.scan.duration)} + {f?.scan?.duration !== undefined + ? formatDuration(f.scan.duration) + : "-"}
- + - +
- + - {f.scan.scheduledAt && ( + {f?.scan?.scheduledAt && ( )}
- ) : ( -

- Scan information is not available. -

)}
@@ -1012,25 +1111,165 @@ export function ResourceDetailDrawerContent({ value="events" className="flex min-h-0 flex-1 flex-col gap-4" > - + {isNavigating ? ( + + ) : ( + <> + + + )} {/* Lighthouse AI button */} - - - Analyze This Finding With Lighthouse AI - + {!isNavigating && ( + + + Analyze This Finding With Lighthouse AI + + )} + + ); +} + +function OverviewNavigationSkeleton() { + return ( +
+ + + + + + + + + +
+ ); +} + +function OverviewCardSkeleton({ lineWidths }: { lineWidths: string[] }) { + return ( + + ); +} + +function OtherFindingsNavigationSkeletonRows() { + return ( + <> + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + + ); +} + +function ScansNavigationSkeleton() { + return ( + + ); +} + +function ScansInfoGridSkeleton({ labels }: { labels: string[] }) { + const columnCount = labels.length; + + return ( +
+ {labels.map((label, index) => ( +
+ {label} + +
+ ))} +
+ ); +} + +function EventsNavigationSkeleton() { + return ( + ); } diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx index b8837ea596..65ad4e734c 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx @@ -11,6 +11,7 @@ import { DrawerHeader, DrawerTitle, } from "@/components/shadcn"; +import type { FindingResourceRow } from "@/types"; import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content"; import type { CheckMeta } from "./use-resource-detail-drawer"; @@ -23,8 +24,10 @@ interface ResourceDetailDrawerProps { checkMeta: CheckMeta | null; currentIndex: number; totalResources: number; + currentResource: FindingResourceRow | null; currentFinding: ResourceDrawerFinding | null; otherFindings: ResourceDrawerFinding[]; + showSyntheticResourceHint?: boolean; onNavigatePrev: () => void; onNavigateNext: () => void; onMuteComplete: () => void; @@ -38,8 +41,10 @@ export function ResourceDetailDrawer({ checkMeta, currentIndex, totalResources, + currentResource, currentFinding, otherFindings, + showSyntheticResourceHint = false, onNavigatePrev, onNavigateNext, onMuteComplete, @@ -64,8 +69,10 @@ export function ResourceDetailDrawer({ checkMeta={checkMeta} currentIndex={currentIndex} totalResources={totalResources} + currentResource={currentResource} currentFinding={currentFinding} otherFindings={otherFindings} + showSyntheticResourceHint={showSyntheticResourceHint} onNavigatePrev={onNavigatePrev} onNavigateNext={onNavigateNext} onMuteComplete={onMuteComplete} diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index b3729fd2cc..f767184598 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -6,14 +6,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- const { + getFindingByIdMock, getLatestFindingsByResourceUidMock, adaptFindingsByResourceResponseMock, } = vi.hoisted(() => ({ + getFindingByIdMock: vi.fn(), getLatestFindingsByResourceUidMock: vi.fn(), adaptFindingsByResourceResponseMock: vi.fn(), })); vi.mock("@/actions/findings", () => ({ + getFindingById: getFindingByIdMock, getLatestFindingsByResourceUid: getLatestFindingsByResourceUidMock, adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock, })); @@ -109,6 +112,7 @@ describe("useResourceDetailDrawer — unmount cleanup", () => { beforeEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); }); it("should abort the in-flight fetch controller when the hook unmounts", async () => { @@ -116,9 +120,7 @@ describe("useResourceDetailDrawer — unmount cleanup", () => { const abortSpy = vi.spyOn(AbortController.prototype, "abort"); // never-resolving fetch to simulate in-flight request - getLatestFindingsByResourceUidMock.mockImplementation( - () => new Promise(() => {}), - ); + getFindingByIdMock.mockImplementation(() => new Promise(() => {})); adaptFindingsByResourceResponseMock.mockReturnValue([]); const resources = [makeResource()]; @@ -126,7 +128,6 @@ describe("useResourceDetailDrawer — unmount cleanup", () => { const { result, unmount } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -136,7 +137,7 @@ describe("useResourceDetailDrawer — unmount cleanup", () => { }); // Verify a fetch was started - expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(1); + expect(getFindingByIdMock).toHaveBeenCalledTimes(1); // Reset spy count to detect only the unmount abort abortSpy.mockClear(); @@ -158,7 +159,6 @@ describe("useResourceDetailDrawer — unmount cleanup", () => { const { unmount } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -173,40 +173,63 @@ describe("useResourceDetailDrawer — unmount cleanup", () => { describe("useResourceDetailDrawer — other findings filtering", () => { beforeEach(() => { vi.clearAllMocks(); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); }); - it("should exclude the current finding from otherFindings and preserve API order", async () => { + it("should load other findings from the current resource uid and exclude the current finding", async () => { const resources = [makeResource()]; - getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); - adaptFindingsByResourceResponseMock.mockReturnValue([ - makeDrawerFinding({ - id: "current", - checkId: "s3_check", - checkTitle: "Current", - status: "FAIL", - severity: "critical", - }), - makeDrawerFinding({ - id: "other-1", - checkId: "check-other-1", - checkTitle: "Other 1", - status: "FAIL", - severity: "critical", - }), - makeDrawerFinding({ - id: "other-2", - checkId: "check-other-2", - checkTitle: "Other 2", - status: "FAIL", - severity: "medium", - }), - ]); + // Given + getFindingByIdMock.mockResolvedValue({ data: ["detail"] }); + getLatestFindingsByResourceUidMock.mockResolvedValue({ + data: ["resource"], + }); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => { + if (response.data[0] === "detail") { + return [ + makeDrawerFinding({ + id: "finding-1", + checkId: "s3_check", + checkTitle: "Current", + status: "MANUAL", + severity: "informational", + }), + ]; + } + + return [ + makeDrawerFinding({ + id: "finding-3", + checkTitle: "First other finding", + status: "FAIL", + severity: "high", + }), + makeDrawerFinding({ + id: "finding-1", + checkTitle: "Current finding duplicate from resource fetch", + 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", + status: "FAIL", + severity: "medium", + }), + ]; + }, + ); const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -215,59 +238,65 @@ describe("useResourceDetailDrawer — other findings filtering", () => { await Promise.resolve(); }); + // Then + expect(getFindingByIdMock).toHaveBeenCalledWith( + "finding-1", + "resources,scan.provider", + { source: "resource-detail-drawer" }, + ); + expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({ + resourceUid: "arn:aws:s3:::my-bucket", + pageSize: 50, + includeMuted: false, + }); + expect(result.current.currentFinding?.id).toBe("finding-1"); expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([ - "other-1", - "other-2", + "finding-3", + "finding-5", ]); }); - it("should exclude non-FAIL findings from otherFindings", async () => { - const resources = [makeResource()]; + it("should skip loading other findings for synthetic IaC resources and keep the current detail on findingId", async () => { + const resources = [ + makeResource({ + findingId: "synthetic-finding", + resourceUid: "synthetic://iac-resource", + }), + ]; - getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + // Given + getFindingByIdMock.mockResolvedValue({ data: ["detail"] }); adaptFindingsByResourceResponseMock.mockReturnValue([ makeDrawerFinding({ - id: "current", + id: "synthetic-finding", checkId: "s3_check", status: "MANUAL", severity: "informational", }), - makeDrawerFinding({ - id: "other-pass", - checkId: "check-pass", - status: "PASS", - severity: "low", - }), - makeDrawerFinding({ - id: "other-manual", - checkId: "check-manual", - status: "MANUAL", - severity: "low", - }), - makeDrawerFinding({ - id: "other-fail", - checkId: "check-fail", - status: "FAIL", - severity: "high", - }), ]); const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", + canLoadOtherFindings: false, }), ); await act(async () => { + // When result.current.openDrawer(0); await Promise.resolve(); }); - expect(result.current.currentFinding?.id).toBe("current"); - expect(result.current.otherFindings.map((f) => f.id)).toEqual([ - "other-fail", - ]); + // Then + expect(getFindingByIdMock).toHaveBeenCalledWith( + "synthetic-finding", + "resources,scan.provider", + { source: "resource-detail-drawer" }, + ); + expect(getLatestFindingsByResourceUidMock).not.toHaveBeenCalled(); + expect(result.current.currentFinding?.id).toBe("synthetic-finding"); + expect(result.current.otherFindings).toEqual([]); }); it("should request muted findings only when explicitly enabled", async () => { @@ -279,7 +308,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", includeMutedInOtherFindings: true, }), ); @@ -291,6 +319,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => { expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({ resourceUid: "arn:aws:s3:::my-bucket", + pageSize: 50, includeMuted: true, }); }); @@ -313,19 +342,19 @@ describe("useResourceDetailDrawer — other findings filtering", () => { }), ]; - getLatestFindingsByResourceUidMock.mockImplementation( - async ({ resourceUid }: { resourceUid: string }) => ({ - data: [resourceUid], - }), - ); + getFindingByIdMock.mockImplementation(async (findingId: string) => ({ + data: [findingId], + })); adaptFindingsByResourceResponseMock.mockImplementation( (response: { data: string[] }) => [ makeDrawerFinding({ - id: response.data[0].includes("first") ? "finding-1" : "finding-2", - resourceUid: response.data[0], - resourceName: response.data[0].includes("first") - ? "first-bucket" - : "second-bucket", + id: response.data[0], + resourceUid: + response.data[0] === "finding-1" + ? "arn:aws:s3:::first-bucket" + : "arn:aws:s3:::second-bucket", + resourceName: + response.data[0] === "finding-1" ? "first-bucket" : "second-bucket", }), ], ); @@ -333,7 +362,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -358,6 +386,8 @@ describe("useResourceDetailDrawer — other findings filtering", () => { expect(result.current.isNavigating).toBe(true); await act(async () => { + await Promise.resolve(); + await Promise.resolve(); vi.runAllTimers(); await Promise.resolve(); }); @@ -387,19 +417,19 @@ describe("useResourceDetailDrawer — other findings filtering", () => { }), ]; - getLatestFindingsByResourceUidMock.mockImplementation( - async ({ resourceUid }: { resourceUid: string }) => ({ - data: [resourceUid], - }), - ); + getFindingByIdMock.mockImplementation(async (findingId: string) => ({ + data: [findingId], + })); adaptFindingsByResourceResponseMock.mockImplementation( (response: { data: string[] }) => [ makeDrawerFinding({ - id: response.data[0].includes("first") ? "finding-1" : "finding-2", - resourceUid: response.data[0], - resourceName: response.data[0].includes("first") - ? "first-bucket" - : "second-bucket", + id: response.data[0], + resourceUid: + response.data[0] === "finding-1" + ? "arn:aws:s3:::first-bucket" + : "arn:aws:s3:::second-bucket", + resourceName: + response.data[0] === "finding-1" ? "first-bucket" : "second-bucket", }), ], ); @@ -407,7 +437,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -452,6 +481,154 @@ describe("useResourceDetailDrawer — other findings filtering", () => { vi.useRealTimers(); }); + it("should update checkMeta when navigating to a resource with a different check", async () => { + // Given + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + checkId: "s3_check", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + checkId: "ec2_check", + resourceUid: "arn:aws:ec2:::instance/i-123", + resourceName: "instance-1", + service: "ec2", + }), + ]; + + getFindingByIdMock.mockImplementation(async (findingId: string) => ({ + data: [findingId], + })); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => [ + response.data[0] === "finding-1" + ? makeDrawerFinding({ + id: "finding-1", + checkId: "s3_check", + checkTitle: "S3 Check", + description: "s3 description", + }) + : makeDrawerFinding({ + id: "finding-2", + checkId: "ec2_check", + checkTitle: "EC2 Check", + description: "ec2 description", + }), + ], + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + }), + ); + + // When + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); + + await act(async () => { + result.current.navigateNext(); + await Promise.resolve(); + }); + + // Then + expect(result.current.checkMeta?.checkTitle).toBe("EC2 Check"); + expect(result.current.checkMeta?.description).toBe("ec2 description"); + }); + + it("should keep the previous check metadata cached while reopening until the new finding arrives", async () => { + // Given + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + checkId: "s3_check", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + checkId: "ec2_check", + resourceUid: "arn:aws:ec2:::instance/i-123", + resourceName: "instance-1", + service: "ec2", + }), + ]; + + let resolveSecondFinding: ((value: { data: string[] }) => void) | null = + null; + + getFindingByIdMock.mockImplementation((findingId: string) => { + if (findingId === "finding-2") { + return new Promise((resolve) => { + resolveSecondFinding = resolve; + }); + } + + return Promise.resolve({ data: [findingId] }); + }); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => [ + response.data[0] === "finding-1" + ? makeDrawerFinding({ + id: "finding-1", + checkId: "s3_check", + checkTitle: "S3 Check", + description: "s3 description", + }) + : makeDrawerFinding({ + id: "finding-2", + checkId: "ec2_check", + checkTitle: "EC2 Check", + description: "ec2 description", + }), + ], + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); + + // When + act(() => { + result.current.closeDrawer(); + result.current.openDrawer(1); + }); + + // Then + expect(result.current.isOpen).toBe(true); + expect(result.current.currentIndex).toBe(1); + expect(result.current.currentFinding).toBeNull(); + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); + + await act(async () => { + resolveSecondFinding?.({ data: ["finding-2"] }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.checkMeta?.checkTitle).toBe("EC2 Check"); + expect(result.current.checkMeta?.description).toBe("ec2 description"); + }); + it("should clear the previous resource findings when navigation to the next resource fails", async () => { // Given const resources = [ @@ -469,24 +646,24 @@ describe("useResourceDetailDrawer — other findings filtering", () => { }), ]; - getLatestFindingsByResourceUidMock.mockImplementation( - async ({ resourceUid }: { resourceUid: string }) => { - if (resourceUid.includes("second")) { - throw new Error("Fetch failed"); - } + getFindingByIdMock.mockImplementation(async (findingId: string) => { + if (findingId === "finding-2") { + throw new Error("Fetch failed"); + } - return { data: [resourceUid] }; - }, - ); + return { data: [findingId] }; + }); adaptFindingsByResourceResponseMock.mockImplementation( (response: { data: string[] }) => [ makeDrawerFinding({ - id: response.data[0].includes("first") ? "finding-1" : "finding-2", - resourceUid: response.data[0], - resourceName: response.data[0].includes("first") - ? "first-bucket" - : "second-bucket", + id: response.data[0], + resourceUid: + response.data[0] === "finding-1" + ? "arn:aws:s3:::first-bucket" + : "arn:aws:s3:::second-bucket", + resourceName: + response.data[0] === "finding-1" ? "first-bucket" : "second-bucket", }), ], ); @@ -494,7 +671,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -506,6 +682,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => { expect(result.current.currentFinding?.resourceUid).toBe( "arn:aws:s3:::first-bucket", ); + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); // When await act(async () => { @@ -517,5 +694,123 @@ describe("useResourceDetailDrawer — other findings filtering", () => { expect(result.current.currentIndex).toBe(1); expect(result.current.currentFinding).toBeNull(); expect(result.current.otherFindings).toEqual([]); + expect(result.current.checkMeta).toBeNull(); + }); + + it("should clear other findings immediately while the next resource is loading", async () => { + // Given + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + resourceUid: "arn:aws:s3:::first-bucket", + resourceName: "first-bucket", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + resourceUid: "arn:aws:s3:::second-bucket", + resourceName: "second-bucket", + }), + ]; + + let resolveSecondFinding: ((value: { data: string[] }) => void) | null = + null; + let resolveSecondResource: ((value: { data: string[] }) => void) | null = + null; + + getFindingByIdMock.mockImplementation((findingId: string) => { + if (findingId === "finding-2") { + return new Promise((resolve) => { + resolveSecondFinding = resolve; + }); + } + + return Promise.resolve({ data: [findingId] }); + }); + + getLatestFindingsByResourceUidMock.mockImplementation( + ({ resourceUid }: { resourceUid: string }) => { + if (resourceUid === "arn:aws:s3:::second-bucket") { + return new Promise((resolve) => { + resolveSecondResource = resolve; + }); + } + + return Promise.resolve({ data: ["resource-1"] }); + }, + ); + + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => { + if (response.data[0] === "finding-1") { + return [makeDrawerFinding({ id: "finding-1" })]; + } + + if (response.data[0] === "finding-2") { + return [ + makeDrawerFinding({ + id: "finding-2", + resourceUid: "arn:aws:s3:::second-bucket", + resourceName: "second-bucket", + }), + ]; + } + + if (response.data[0] === "resource-1") { + return [ + makeDrawerFinding({ + id: "finding-3", + checkTitle: "First bucket other finding", + resourceUid: "arn:aws:s3:::first-bucket", + }), + ]; + } + + return [ + makeDrawerFinding({ + id: "finding-4", + checkTitle: "Second bucket other finding", + resourceUid: "arn:aws:s3:::second-bucket", + }), + ]; + }, + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([ + "finding-3", + ]); + + // When + act(() => { + result.current.navigateNext(); + }); + + // Then + expect(result.current.currentIndex).toBe(1); + expect(result.current.currentFinding).toBeNull(); + expect(result.current.otherFindings).toEqual([]); + + await act(async () => { + resolveSecondFinding?.({ data: ["finding-2"] }); + resolveSecondResource?.({ data: ["resource-2"] }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([ + "finding-4", + ]); }); }); diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index 3affd7d38a..7a85b3d994 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { adaptFindingsByResourceResponse, + getFindingById, getLatestFindingsByResourceUid, type ResourceDrawerFinding, } from "@/actions/findings"; @@ -43,10 +44,10 @@ function extractCheckMeta(finding: ResourceDrawerFinding): CheckMeta { interface UseResourceDetailDrawerOptions { resources: FindingResourceRow[]; - checkId: string; totalResourceCount?: number; onRequestMoreResources?: () => void; initialIndex?: number | null; + canLoadOtherFindings?: boolean; includeMutedInOtherFindings?: boolean; } @@ -57,9 +58,9 @@ interface UseResourceDetailDrawerReturn { checkMeta: CheckMeta | null; currentIndex: number; totalResources: number; + currentResource: FindingResourceRow | null; currentFinding: ResourceDrawerFinding | null; otherFindings: ResourceDrawerFinding[]; - allFindings: ResourceDrawerFinding[]; openDrawer: (index: number) => void; closeDrawer: () => void; navigatePrev: () => void; @@ -71,24 +72,33 @@ interface UseResourceDetailDrawerReturn { /** * Manages the resource detail drawer state, fetching, and navigation. * - * Caches findings per resourceUid in a Map ref so navigating prev/next + * Caches findings per findingId in a Map ref so navigating prev/next * doesn't re-fetch already-visited resources. */ export function useResourceDetailDrawer({ resources, - checkId, totalResourceCount, onRequestMoreResources, initialIndex = null, + canLoadOtherFindings = true, includeMutedInOtherFindings = false, }: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn { const [isOpen, setIsOpen] = useState(initialIndex !== null); const [isLoading, setIsLoading] = useState(false); const [currentIndex, setCurrentIndex] = useState(initialIndex ?? 0); - const [findings, setFindings] = useState([]); + const [currentFinding, setCurrentFinding] = + useState(null); + const [otherFindings, setOtherFindings] = useState( + [], + ); const [isNavigating, setIsNavigating] = useState(false); - const cacheRef = useRef>(new Map()); + const currentFindingCacheRef = useRef< + Map + >(new Map()); + const otherFindingsCacheRef = useRef>( + new Map(), + ); const checkMetaRef = useRef(null); const fetchControllerRef = useRef(null); const navigationTimeoutRef = useRef | null>( @@ -136,6 +146,11 @@ export function useResourceDetailDrawer({ setIsNavigating(true); }; + const resetCurrentResourceState = () => { + setCurrentFinding(null); + setOtherFindings([]); + }; + // Abort any in-flight request on unmount to prevent state updates // on an already-unmounted component. useEffect(() => { @@ -146,49 +161,83 @@ export function useResourceDetailDrawer({ }; }, []); - const fetchFindings = async (resourceUid: string) => { + const fetchFindings = async (resource: FindingResourceRow) => { // Abort any in-flight request to prevent stale data from out-of-order responses fetchControllerRef.current?.abort(); clearNavigationTimeout(); const controller = new AbortController(); fetchControllerRef.current = controller; - // Check cache first - const cached = cacheRef.current.get(resourceUid); - if (cached) { - if (!checkMetaRef.current) { - const main = cached.find((f) => f.checkId === checkId) ?? cached[0]; - if (main) checkMetaRef.current = extractCheckMeta(main); + const { findingId, resourceUid } = resource; + + const fetchCurrentFinding = async () => { + const cached = currentFindingCacheRef.current.get(findingId); + if (cached !== undefined) { + return cached; } - setFindings(cached); - finishNavigation(); - return; - } + + const response = await getFindingById( + findingId, + "resources,scan.provider", + { source: "resource-detail-drawer" }, + ); + + const adapted = adaptFindingsByResourceResponse(response); + const finding = + adapted.find((item) => item.id === findingId) ?? adapted[0] ?? null; + + currentFindingCacheRef.current.set(findingId, finding); + + return finding; + }; + + const fetchOtherFindings = async () => { + if (!canLoadOtherFindings || !resourceUid) { + return []; + } + + const cached = otherFindingsCacheRef.current.get(resourceUid); + if (cached) { + return cached; + } + + const response = await getLatestFindingsByResourceUid({ + resourceUid, + pageSize: 50, + includeMuted: includeMutedInOtherFindings, + }); + const adapted = adaptFindingsByResourceResponse(response); + + otherFindingsCacheRef.current.set(resourceUid, adapted); + + return adapted; + }; setIsLoading(true); try { - const response = await getLatestFindingsByResourceUid({ - resourceUid, - includeMuted: includeMutedInOtherFindings, - }); + const [nextCurrentFinding, nextOtherFindings] = await Promise.all([ + fetchCurrentFinding(), + fetchOtherFindings(), + ]); // Discard stale response if a newer request was started if (controller.signal.aborted) return; - const adapted = adaptFindingsByResourceResponse(response); - cacheRef.current.set(resourceUid, adapted); + checkMetaRef.current = nextCurrentFinding + ? extractCheckMeta(nextCurrentFinding) + : null; - // Extract check-level metadata once (stable across all resources) - if (!checkMetaRef.current) { - const main = adapted.find((f) => f.checkId === checkId) ?? adapted[0]; - if (main) checkMetaRef.current = extractCheckMeta(main); - } - - setFindings(adapted); - } catch (error) { + setCurrentFinding(nextCurrentFinding); + setOtherFindings( + nextOtherFindings.filter( + (finding) => finding.id !== findingId && finding.status === "FAIL", + ), + ); + } catch (_error) { if (!controller.signal.aborted) { - console.error("Error fetching findings for resource:", error); - setFindings([]); + checkMetaRef.current = null; + setCurrentFinding(null); + setOtherFindings([]); } } finally { if (!controller.signal.aborted) { @@ -207,7 +256,7 @@ export function useResourceDetailDrawer({ return; } - fetchFindings(resource.resourceUid); + fetchFindings(resource); // Only initialize once on mount for deep-link/inline entry points. // User-driven navigations use openDrawer/navigateTo afterwards. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -217,13 +266,11 @@ export function useResourceDetailDrawer({ const resource = resources[index]; if (!resource) return; - clearNavigationTimeout(); - navigationStartedAtRef.current = null; setCurrentIndex(index); setIsOpen(true); - setIsNavigating(false); - setFindings([]); - fetchFindings(resource.resourceUid); + startNavigation(); + resetCurrentResourceState(); + fetchFindings(resource); }; const closeDrawer = () => { @@ -233,10 +280,11 @@ export function useResourceDetailDrawer({ const refetchCurrent = () => { const resource = resources[currentIndex]; if (!resource) return; - cacheRef.current.delete(resource.resourceUid); + currentFindingCacheRef.current.delete(resource.findingId); + otherFindingsCacheRef.current.delete(resource.resourceUid); startNavigation(); - setFindings([]); - fetchFindings(resource.resourceUid); + resetCurrentResourceState(); + fetchFindings(resource); }; const navigateTo = (index: number) => { @@ -245,8 +293,8 @@ export function useResourceDetailDrawer({ setCurrentIndex(index); startNavigation(); - setFindings([]); - fetchFindings(resource.resourceUid); + resetCurrentResourceState(); + fetchFindings(resource); }; const navigatePrev = () => { @@ -270,17 +318,7 @@ export function useResourceDetailDrawer({ } }; - // The finding whose checkId matches the drill-down's checkId - const currentFinding = - findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null; - - // "Other Findings For This Resource" intentionally shows only FAIL entries, - // while currentFinding (the drilled-down one) can be any status (FAIL, MANUAL, PASS…). - const otherFindings = ( - currentFinding - ? findings.filter((f) => f.id !== currentFinding.id) - : findings - ).filter((f) => f.status === "FAIL"); + const currentResource = resources[currentIndex]; return { isOpen, @@ -289,9 +327,9 @@ export function useResourceDetailDrawer({ checkMeta: checkMetaRef.current, currentIndex, totalResources: totalResourceCount ?? resources.length, + currentResource: currentResource ?? null, currentFinding, otherFindings, - allFindings: findings, openDrawer, closeDrawer, navigatePrev, diff --git a/ui/hooks/use-finding-group-resource-state.ts b/ui/hooks/use-finding-group-resource-state.ts index f313bb77b0..309a69a6dc 100644 --- a/ui/hooks/use-finding-group-resource-state.ts +++ b/ui/hooks/use-finding-group-resource-state.ts @@ -80,9 +80,9 @@ export function useFindingGroupResourceState({ const drawer = useResourceDetailDrawer({ resources, - checkId: group.checkId, totalResourceCount: totalCount ?? group.resourcesTotal, onRequestMoreResources: loadMore, + canLoadOtherFindings: group.resourcesTotal !== 0, includeMutedInOtherFindings: true, }); diff --git a/ui/lib/findings-groups.test.ts b/ui/lib/findings-groups.test.ts index 9fa73c5b1c..1d319371de 100644 --- a/ui/lib/findings-groups.test.ts +++ b/ui/lib/findings-groups.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest"; import type { FindingGroupRow } from "@/types"; import { + canDrillDownFindingGroup, getActiveStatusFilter, getFilteredFindingGroupDelta, getFindingGroupDelta, + getFindingGroupImpactedCounts, isFindingGroupMuted, } from "./findings-groups"; @@ -138,6 +140,119 @@ describe("getActiveStatusFilter", () => { }); }); +describe("getFindingGroupImpactedCounts", () => { + it("should fall back to pass and fail counts when resources total is zero", () => { + // Given + const group = makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + muted: false, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 3, total: 5 }); + }); + + it("should include manual findings in fallback counts when resources total is zero", () => { + // Given + const group = makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + manualCount: 4, + muted: false, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 3, total: 9 }); + }); + + it("should include muted pass and fail counts in the denominator when the result is muted", () => { + // Given + const group = makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + failMutedCount: 4, + passMutedCount: 1, + muted: true, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 3, total: 10 }); + }); + + it("should keep resource-based counts when resources total is available", () => { + // Given + const group = makeGroup({ + resourcesTotal: 6, + resourcesFail: 4, + failCount: 2, + passCount: 1, + failMutedCount: 5, + passMutedCount: 3, + muted: true, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 4, total: 6 }); + }); +}); + +describe("canDrillDownFindingGroup", () => { + it("should allow drill-down when resources exist", () => { + expect( + canDrillDownFindingGroup( + makeGroup({ + resourcesTotal: 2, + failCount: 0, + }), + ), + ).toBe(true); + }); + + it("should keep zero-resource fallback groups non-expandable even when fallback counts are present", () => { + expect( + canDrillDownFindingGroup( + makeGroup({ + resourcesTotal: 0, + failCount: 0, + passCount: 2, + manualCount: 1, + }), + ), + ).toBe(false); + }); + + it("should keep drill-down disabled for zero-resource groups when the displayed total is zero", () => { + expect( + canDrillDownFindingGroup( + makeGroup({ + resourcesTotal: 0, + failCount: 0, + passCount: 0, + }), + ), + ).toBe(false); + }); +}); + describe("getFilteredFindingGroupDelta", () => { it("falls back to the aggregate delta when no status filter is active", () => { expect( diff --git a/ui/lib/findings-groups.ts b/ui/lib/findings-groups.ts index 7f15e9ad2c..60f1911c05 100644 --- a/ui/lib/findings-groups.ts +++ b/ui/lib/findings-groups.ts @@ -1,9 +1,10 @@ -import type { FindingGroupRow } from "@/types"; - -type FindingGroupMutedState = Pick< - FindingGroupRow, - "muted" | "mutedCount" | "resourcesFail" | "resourcesTotal" ->; +import { + FINDING_DELTA, + FINDING_STATUS, + type FindingDelta, + type FindingGroupRow, + type FindingStatus, +} from "@/types"; type FindingGroupDeltaState = Pick< FindingGroupRow, @@ -23,7 +24,18 @@ type FindingGroupDeltaState = Pick< | "changedManualMutedCount" >; -export function isFindingGroupMuted(group: FindingGroupMutedState): boolean { +type FindingGroupDelta = Exclude; + +type FindingGroupStatus = FindingStatus; + +const FINDING_GROUP_STATUSES = Object.values(FINDING_STATUS); + +export function isFindingGroupMuted( + group: Pick< + FindingGroupRow, + "muted" | "mutedCount" | "resourcesFail" | "resourcesTotal" + >, +): boolean { if (typeof group.muted === "boolean") { return group.muted; } @@ -38,6 +50,54 @@ export function isFindingGroupMuted(group: FindingGroupMutedState): boolean { ); } +export function getFindingGroupImpactedCounts( + group: Pick< + FindingGroupRow, + | "resourcesTotal" + | "resourcesFail" + | "passCount" + | "failCount" + | "manualCount" + | "passMutedCount" + | "failMutedCount" + | "manualMutedCount" + | "muted" + | "mutedCount" + >, +): { impacted: number; total: number } { + if (group.resourcesTotal > 0) { + return { + impacted: group.resourcesFail, + total: group.resourcesTotal, + }; + } + + const total = + (group.passCount ?? 0) + (group.failCount ?? 0) + (group.manualCount ?? 0); + + if (!isFindingGroupMuted(group)) { + return { + impacted: group.failCount ?? 0, + total, + }; + } + + return { + impacted: group.failCount ?? 0, + total: + total + + (group.passMutedCount ?? 0) + + (group.failMutedCount ?? 0) + + (group.manualMutedCount ?? 0), + }; +} + +export function canDrillDownFindingGroup( + group: Pick, +): boolean { + return group.resourcesTotal > 0; +} + function getNewDeltaTotal(group: FindingGroupDeltaState): number { const breakdownTotal = (group.newFailCount ?? 0) + @@ -64,21 +124,18 @@ function getChangedDeltaTotal(group: FindingGroupDeltaState): number { export function getFindingGroupDelta( group: FindingGroupDeltaState, -): "new" | "changed" | "none" { +): FindingGroupDelta { if (getNewDeltaTotal(group) > 0) { - return "new"; + return FINDING_DELTA.NEW; } if (getChangedDeltaTotal(group) > 0) { - return "changed"; + return FINDING_DELTA.CHANGED; } - return "none"; + return FINDING_DELTA.NONE; } -const FINDING_GROUP_STATUSES = ["FAIL", "PASS", "MANUAL"] as const; -type FindingGroupStatus = (typeof FINDING_GROUP_STATUSES)[number]; - type FindingGroupFiltersRecord = Record; function parseStatusFilterValue( @@ -142,13 +199,13 @@ function getNewDeltaForStatuses( statuses: Set, ): number { let total = 0; - if (statuses.has("FAIL")) { + if (statuses.has(FINDING_STATUS.FAIL)) { total += (group.newFailCount ?? 0) + (group.newFailMutedCount ?? 0); } - if (statuses.has("PASS")) { + if (statuses.has(FINDING_STATUS.PASS)) { total += (group.newPassCount ?? 0) + (group.newPassMutedCount ?? 0); } - if (statuses.has("MANUAL")) { + if (statuses.has(FINDING_STATUS.MANUAL)) { total += (group.newManualCount ?? 0) + (group.newManualMutedCount ?? 0); } return total; @@ -159,13 +216,13 @@ function getChangedDeltaForStatuses( statuses: Set, ): number { let total = 0; - if (statuses.has("FAIL")) { + if (statuses.has(FINDING_STATUS.FAIL)) { total += (group.changedFailCount ?? 0) + (group.changedFailMutedCount ?? 0); } - if (statuses.has("PASS")) { + if (statuses.has(FINDING_STATUS.PASS)) { total += (group.changedPassCount ?? 0) + (group.changedPassMutedCount ?? 0); } - if (statuses.has("MANUAL")) { + if (statuses.has(FINDING_STATUS.MANUAL)) { total += (group.changedManualCount ?? 0) + (group.changedManualMutedCount ?? 0); } @@ -182,7 +239,7 @@ function getChangedDeltaForStatuses( export function getFilteredFindingGroupDelta( group: FindingGroupDeltaState, filters: FindingGroupFiltersRecord, -): "new" | "changed" | "none" { +): FindingGroupDelta { const activeStatuses = getActiveStatusFilter(filters); if (!activeStatuses || !hasAnyDeltaBreakdown(group)) { @@ -190,12 +247,12 @@ export function getFilteredFindingGroupDelta( } if (getNewDeltaForStatuses(group, activeStatuses) > 0) { - return "new"; + return FINDING_DELTA.NEW; } if (getChangedDeltaForStatuses(group, activeStatuses) > 0) { - return "changed"; + return FINDING_DELTA.CHANGED; } - return "none"; + return FINDING_DELTA.NONE; } diff --git a/ui/types/components.ts b/ui/types/components.ts index 0f294ceb7d..62642db4ea 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -82,6 +82,7 @@ export type PermissionState = export const FINDING_DELTA = { NEW: "new", CHANGED: "changed", + NONE: "none", } as const; export type FindingDelta = | (typeof FINDING_DELTA)[keyof typeof FINDING_DELTA]