From eebb09503d0669a561e0f23749a710fb7f9f6c5e Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Wed, 18 Mar 2026 19:07:41 +0100 Subject: [PATCH] feat(ui): add resource detail drawer for finding group drill-down --- .../findings/findings-by-resource.adapter.ts | 169 ++++++ ui/actions/findings/findings-by-resource.ts | 35 ++ ui/actions/findings/index.ts | 2 + .../table/findings-group-drill-down.tsx | 34 +- .../table/resource-detail-drawer/index.ts | 2 + .../resource-detail-drawer-content.tsx | 519 ++++++++++++++++++ .../resource-detail-drawer.tsx | 70 +++ .../use-resource-detail-drawer.ts | 135 +++++ 8 files changed, 965 insertions(+), 1 deletion(-) create mode 100644 ui/actions/findings/findings-by-resource.adapter.ts create mode 100644 ui/actions/findings/findings-by-resource.ts create mode 100644 ui/components/findings/table/resource-detail-drawer/index.ts create mode 100644 ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx create mode 100644 ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx create mode 100644 ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts new file mode 100644 index 0000000000..e9ba68ab0e --- /dev/null +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -0,0 +1,169 @@ +import { createDict } from "@/lib"; +import { ProviderType, Severity } from "@/types"; + +/** + * Flattened finding for the resource detail drawer. + * Merges data from the finding attributes, its check_metadata, + * the included resource, and the included scan/provider. + */ +export interface ResourceDrawerFinding { + id: string; + uid: string; + checkId: string; + checkTitle: string; + status: string; + severity: Severity; + delta: string | null; + isMuted: boolean; + mutedReason: string | null; + firstSeenAt: string | null; + updatedAt: string | null; + // Resource + resourceUid: string; + resourceName: string; + resourceService: string; + resourceRegion: string; + resourceType: string; + // Provider + providerType: ProviderType; + providerAlias: string; + providerUid: string; + // Check metadata (flattened) + risk: string; + description: string; + statusExtended: string; + complianceFrameworks: string[]; + categories: string[]; + remediation: { + recommendation: { text: string; url: string }; + code: { + cli: string; + other: string; + nativeiac: string; + terraform: string; + }; + }; + additionalUrls: string[]; +} + +/** + * Extracts unique compliance framework names from available data. + * + * Supports two shapes: + * 1. check_metadata.compliance — array of { Framework, Version, ... } objects + * e.g. [{ Framework: "CIS-AWS", Version: "1.4" }, { Framework: "PCI-DSS" }] + * 2. finding.compliance — dict with versioned keys (when API exposes it) + * e.g. {"CIS-AWS-1.4": ["2.1"], "PCI-DSS-3.2": ["6.2"]} + */ +function extractComplianceFrameworks( + metaCompliance: unknown, + findingCompliance: Record | null | undefined, +): string[] { + const frameworks = new Set(); + + // Source 1: check_metadata.compliance — array of objects with Framework field + if (Array.isArray(metaCompliance)) { + for (const entry of metaCompliance) { + if (entry?.Framework || entry?.framework) { + frameworks.add(entry.Framework || entry.framework); + } + } + } + + // Source 2: finding.compliance — dict keys like "CIS-AWS-1.4" + if (findingCompliance && typeof findingCompliance === "object") { + for (const key of Object.keys(findingCompliance)) { + const base = key.replace(/-\d+(\.\d+)*$/, ""); + frameworks.add(base); + } + } + + return Array.from(frameworks); +} + +/** + * Transforms the `/findings/latest?include=resources,scan.provider` response + * into a flat ResourceDrawerFinding array. + * + * Uses createDict to build lookup maps from the JSON:API `included` array, + * then resolves each finding's resource and provider relationships. + */ +export function adaptFindingsByResourceResponse( + apiResponse: any, +): ResourceDrawerFinding[] { + if (!apiResponse?.data || !Array.isArray(apiResponse.data)) { + return []; + } + + const resourcesDict = createDict("resources", apiResponse); + const scansDict = createDict("scans", apiResponse); + const providersDict = createDict("providers", apiResponse); + + return apiResponse.data.map((item: any) => { + const attrs = item.attributes; + const meta = attrs.check_metadata || {}; + const remediation = meta.remediation || { + recommendation: { text: "", url: "" }, + code: { cli: "", other: "", nativeiac: "", terraform: "" }, + }; + + // Resolve resource from included + const resourceRel = item.relationships?.resources?.data?.[0]; + const resource = resourceRel ? resourcesDict[resourceRel.id] : null; + const resourceAttrs = resource?.attributes || {}; + + // Resolve provider via scan → provider (include path: scan.provider) + const scanRel = item.relationships?.scan?.data; + const scan = scanRel ? scansDict[scanRel.id] : null; + const providerRelId = + scan?.relationships?.provider?.data?.id ?? null; + const provider = providerRelId ? providersDict[providerRelId] : null; + const providerAttrs = provider?.attributes || {}; + + return { + id: item.id, + uid: attrs.uid, + checkId: attrs.check_id, + checkTitle: meta.checktitle || attrs.check_id, + status: attrs.status, + severity: (attrs.severity || "informational") as Severity, + delta: attrs.delta || null, + isMuted: Boolean(attrs.muted), + mutedReason: attrs.muted_reason || null, + firstSeenAt: attrs.first_seen_at || null, + updatedAt: attrs.updated_at || null, + // Resource + resourceUid: resourceAttrs.uid || "-", + resourceName: resourceAttrs.name || "-", + resourceService: resourceAttrs.service || "-", + resourceRegion: resourceAttrs.region || "-", + resourceType: resourceAttrs.type || "-", + // Provider + providerType: (providerAttrs.provider || "aws") as ProviderType, + providerAlias: providerAttrs.alias || "", + providerUid: providerAttrs.uid || "", + // Check metadata + risk: meta.risk || "", + description: meta.description || "", + statusExtended: attrs.status_extended || "", + complianceFrameworks: extractComplianceFrameworks( + meta.compliance ?? meta.Compliance, + attrs.compliance, + ), + categories: meta.categories || [], + remediation: { + recommendation: { + text: remediation.recommendation?.text || "", + url: remediation.recommendation?.url || "", + }, + code: { + cli: remediation.code?.cli || "", + other: remediation.code?.other || "", + nativeiac: remediation.code?.nativeiac || "", + terraform: remediation.code?.terraform || "", + }, + }, + additionalUrls: meta.additionalurls || [], + }; + }); +} diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts new file mode 100644 index 0000000000..530479921e --- /dev/null +++ b/ui/actions/findings/findings-by-resource.ts @@ -0,0 +1,35 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +export const getLatestFindingsByResourceUid = async ({ + resourceUid, + page = 1, + pageSize = 50, +}: { + resourceUid: string; + page?: number; + pageSize?: number; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/findings/latest?include=resources,scan.provider`, + ); + + url.searchParams.append("filter[resource_uid]", resourceUid); + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + + try { + const findings = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(findings); + } catch (error) { + console.error("Error fetching findings by resource UID:", error); + return undefined; + } +}; diff --git a/ui/actions/findings/index.ts b/ui/actions/findings/index.ts index eb3a674c67..d9fd5ae3b5 100644 --- a/ui/actions/findings/index.ts +++ b/ui/actions/findings/index.ts @@ -1 +1,3 @@ export * from "./findings"; +export * from "./findings-by-resource"; +export * from "./findings-by-resource.adapter"; diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index bce947bb3e..6cc15d0ecc 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -11,6 +11,7 @@ import { ChevronLeft } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; +import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; import { Table, TableBody, @@ -20,7 +21,6 @@ import { TableRow, } from "@/components/ui/table"; import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; -import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; import { useInfiniteResources } from "@/hooks/use-infinite-resources"; import { cn, hasDateOrScanFilter } from "@/lib"; import { FindingGroupRow, FindingResourceRow } from "@/types"; @@ -30,6 +30,10 @@ 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 { + ResourceDetailDrawer, + useResourceDetailDrawer, +} from "./resource-detail-drawer"; interface FindingsGroupDrillDownProps { group: FindingGroupRow; @@ -93,6 +97,17 @@ export function FindingsGroupDrillDown({ onSetLoading: handleSetLoading, }); + // Resource detail drawer + const drawer = useResourceDetailDrawer({ + resources, + checkId: group.checkId, + }); + + const handleDrawerMuteComplete = () => { + drawer.closeDrawer(); + router.refresh(); + }; + // Selection logic for resources const selectedFindingIds = Object.keys(rowSelection) .filter((key) => rowSelection[key]) @@ -225,6 +240,8 @@ export function FindingsGroupDrillDown({ drawer.openDrawer(row.index)} > {row.getVisibleCells().map((cell) => ( @@ -271,6 +288,21 @@ export function FindingsGroupDrillDown({ onComplete={handleMuteComplete} /> )} + + { + if (!open) drawer.closeDrawer(); + }} + isLoading={drawer.isLoading} + currentIndex={drawer.currentIndex} + totalResources={drawer.totalResources} + currentFinding={drawer.currentFinding} + otherFindings={drawer.otherFindings} + onNavigatePrev={drawer.navigatePrev} + onNavigateNext={drawer.navigateNext} + onMuteComplete={handleDrawerMuteComplete} + /> ); } diff --git a/ui/components/findings/table/resource-detail-drawer/index.ts b/ui/components/findings/table/resource-detail-drawer/index.ts new file mode 100644 index 0000000000..a59d02e5d9 --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/index.ts @@ -0,0 +1,2 @@ +export { ResourceDetailDrawer } from "./resource-detail-drawer"; +export { useResourceDetailDrawer } from "./use-resource-detail-drawer"; 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 new file mode 100644 index 0000000000..6a15f64ccd --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -0,0 +1,519 @@ +"use client"; + +import { + CircleArrowRight, + CircleChevronLeft, + CircleChevronRight, + VolumeOff, + VolumeX, +} from "lucide-react"; +import Image from "next/image"; +import { useState } from "react"; +import ReactMarkdown from "react-markdown"; + +import type { ResourceDrawerFinding } from "@/actions/findings"; +import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; +import { getComplianceIcon } from "@/components/icons"; +import { + Badge, + InfoField, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { DateWithTime } from "@/components/ui/entities/date-with-time"; +import { EntityInfo } from "@/components/ui/entities/entity-info"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { SeverityBadge } from "@/components/ui/table/severity-badge"; +import { + type FindingStatus, + StatusFindingBadge, +} from "@/components/ui/table/status-finding-badge"; +import { cn } from "@/lib/utils"; + +import { Muted } from "../../muted"; +import { NotificationIndicator } from "../notification-indicator"; + +interface ResourceDetailDrawerContentProps { + isLoading: boolean; + currentIndex: number; + totalResources: number; + currentFinding: ResourceDrawerFinding | null; + otherFindings: ResourceDrawerFinding[]; + onNavigatePrev: () => void; + onNavigateNext: () => void; + onMuteComplete: () => void; +} + +const MarkdownContainer = ({ children }: { children: string }) => ( +
+ {children} +
+); + +function getFailingForLabel(firstSeenAt: string | null): string | null { + if (!firstSeenAt) return null; + + const start = new Date(firstSeenAt); + if (isNaN(start.getTime())) return null; + + const now = new Date(); + const diffMs = now.getTime() - start.getTime(); + if (diffMs < 0) return null; + + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays < 1) return "< 1 day"; + if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""}`; + + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? "s" : ""}`; + + const diffYears = Math.floor(diffMonths / 12); + return `${diffYears} year${diffYears > 1 ? "s" : ""}`; +} + +export function ResourceDetailDrawerContent({ + isLoading, + currentIndex, + totalResources, + currentFinding, + otherFindings, + onNavigatePrev, + onNavigateNext, + onMuteComplete, +}: ResourceDetailDrawerContentProps) { + const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + + if (isLoading) { + return ( +
+ + + Loading finding details... + +
+ ); + } + + if (!currentFinding) { + return ( +
+

+ No finding data available for this resource. +

+
+ ); + } + + const f = currentFinding; + const hasPrev = currentIndex > 0; + const hasNext = currentIndex < totalResources - 1; + + return ( +
+ {/* Mute modal — rendered outside drawer content to avoid overlay conflicts */} + {!f.isMuted && ( + { + setIsMuteModalOpen(false); + onMuteComplete(); + }} + /> + )} + + {/* Header: status badges + title */} +
+
+ + + +
+ +

+ {f.checkTitle} +

+ + {f.complianceFrameworks.length > 0 && ( +
+ + Compliance Frameworks: + +
+ {f.complianceFrameworks.map((framework) => { + const icon = getComplianceIcon(framework); + return icon ? ( + {framework} + ) : ( + + {framework} + + ); + })} +
+
+ )} +
+ + {/* Navigation: "Impacted Resource (X of N)" */} +
+ + Impacted Resource + {currentIndex + 1} + of + {totalResources} + +
+ + +
+
+ + {/* Resource card */} +
+ {/* Account, Resource, Service, Region, Actions */} +
+ + + + {f.resourceService} + + + {f.resourceRegion} + +
+ + + ) : ( + + ) + } + label={f.isMuted ? "Muted" : "Mute"} + disabled={f.isMuted} + onSelect={() => setIsMuteModalOpen(true)} + /> + +
+
+ + {/* Dates row */} +
+ + + + + + + + + {getFailingForLabel(f.firstSeenAt) || "-"} + + +
+ + {/* IDs row */} +
+ + + + + + + + + +
+ + {/* Tabs */} + +
+ + Finding Overview + + Other Findings For This Resource + + + +
+ + {/* Finding Overview */} + + {f.status === "FAIL" && f.risk && ( + +
+ {f.risk} +
+
+ )} + + {f.description && ( + + {f.description} + + )} + + {f.statusExtended && ( + {f.statusExtended} + )} + + {f.remediation.recommendation.text && ( + +
+ + {f.remediation.recommendation.text} + + {f.remediation.recommendation.url && ( + + Learn more + + )} +
+
+ )} + + {f.remediation.code.cli && ( + +
+ +
+
+ )} + + {f.remediation.code.terraform && ( + +
+ +
+
+ )} + + {f.remediation.code.nativeiac && ( + +
+ +
+
+ )} + + {f.remediation.code.other && ( + + + {f.remediation.code.other} + + + )} + + {f.additionalUrls.length > 0 && ( + +
    + {f.additionalUrls.map((link, idx) => ( +
  • + + {link} + +
  • + ))} +
+
+ )} +
+ + {/* Other Findings For This Resource */} + +
+

+ Failed Findings For This Resource +

+ + {otherFindings.length} Total Entries + +
+ + + + + + + + Status + + + + + Finding + + + + + Severity + + + + + Time + + + + + + + {otherFindings.length > 0 ? ( + otherFindings.map((finding) => ( + + )) + ) : ( + + + + No other findings for this resource. + + + + )} + +
+
+
+
+ + {/* Lighthouse AI button */} + + + View This Finding With Lighthouse AI + +
+ ); +} + +function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) { + const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + + return ( + <> + + + + + + + + + +

+ {finding.checkTitle} +

+
+ + + + + + + + + + ) : ( + + ) + } + label={finding.isMuted ? "Muted" : "Mute"} + disabled={finding.isMuted} + onSelect={() => setIsMuteModalOpen(true)} + /> + + +
+ + ); +} 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 new file mode 100644 index 0000000000..a04706b3be --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { X } from "lucide-react"; + +import type { ResourceDrawerFinding } from "@/actions/findings"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/shadcn"; + +import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content"; + +interface ResourceDetailDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + isLoading: boolean; + currentIndex: number; + totalResources: number; + currentFinding: ResourceDrawerFinding | null; + otherFindings: ResourceDrawerFinding[]; + onNavigatePrev: () => void; + onNavigateNext: () => void; + onMuteComplete: () => void; +} + +export function ResourceDetailDrawer({ + open, + onOpenChange, + isLoading, + currentIndex, + totalResources, + currentFinding, + otherFindings, + onNavigatePrev, + onNavigateNext, + onMuteComplete, +}: ResourceDetailDrawerProps) { + return ( + + + + Resource Finding Details + + View finding details for the selected resource + + + + + Close + + {open && ( + + )} + + + ); +} 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 new file mode 100644 index 0000000000..66a1dc731e --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -0,0 +1,135 @@ +"use client"; + +import { useRef, useState } from "react"; + +import { + adaptFindingsByResourceResponse, + getLatestFindingsByResourceUid, + type ResourceDrawerFinding, +} from "@/actions/findings"; +import { FindingResourceRow } from "@/types"; + +interface UseResourceDetailDrawerOptions { + resources: FindingResourceRow[]; + checkId: string; + onRequestMoreResources?: () => void; +} + +interface UseResourceDetailDrawerReturn { + isOpen: boolean; + isLoading: boolean; + currentIndex: number; + totalResources: number; + currentFinding: ResourceDrawerFinding | null; + otherFindings: ResourceDrawerFinding[]; + allFindings: ResourceDrawerFinding[]; + openDrawer: (index: number) => void; + closeDrawer: () => void; + navigatePrev: () => void; + navigateNext: () => void; +} + +/** + * Manages the resource detail drawer state, fetching, and navigation. + * + * Caches findings per resourceUid in a Map ref so navigating prev/next + * doesn't re-fetch already-visited resources. + */ +export function useResourceDetailDrawer({ + resources, + checkId, + onRequestMoreResources, +}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn { + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [findings, setFindings] = useState([]); + + const cacheRef = useRef>(new Map()); + + const fetchFindings = async (resourceUid: string) => { + // Check cache first + const cached = cacheRef.current.get(resourceUid); + if (cached) { + setFindings(cached); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const response = await getLatestFindingsByResourceUid({ resourceUid }); + const adapted = adaptFindingsByResourceResponse(response); + cacheRef.current.set(resourceUid, adapted); + setFindings(adapted); + } catch (error) { + console.error("Error fetching findings for resource:", error); + setFindings([]); + } finally { + setIsLoading(false); + } + }; + + const openDrawer = (index: number) => { + const resource = resources[index]; + if (!resource) return; + + setCurrentIndex(index); + setIsOpen(true); + setFindings([]); + fetchFindings(resource.resourceUid); + }; + + const closeDrawer = () => { + setIsOpen(false); + }; + + const navigateTo = (index: number) => { + const resource = resources[index]; + if (!resource) return; + + setCurrentIndex(index); + setFindings([]); + fetchFindings(resource.resourceUid); + }; + + const navigatePrev = () => { + if (currentIndex > 0) { + navigateTo(currentIndex - 1); + } + }; + + const navigateNext = () => { + if (currentIndex < resources.length - 1) { + navigateTo(currentIndex + 1); + + // Pre-fetch more resources when nearing the end + if (currentIndex >= resources.length - 3) { + onRequestMoreResources?.(); + } + } + }; + + // The finding whose checkId matches the drill-down's checkId + const currentFinding = + findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null; + + // All other findings for this resource + const otherFindings = currentFinding + ? findings.filter((f) => f.id !== currentFinding.id) + : findings; + + return { + isOpen, + isLoading, + currentIndex, + totalResources: resources.length, + currentFinding, + otherFindings, + allFindings: findings, + openDrawer, + closeDrawer, + navigatePrev, + navigateNext, + }; +}