feat(ui): add resource detail drawer for finding group drill-down

This commit is contained in:
alejandrobailo
2026-03-18 19:07:41 +01:00
parent 776a5a443e
commit eebb09503d
8 changed files with 965 additions and 1 deletions

View File

@@ -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<string, string[]> | null | undefined,
): string[] {
const frameworks = new Set<string>();
// 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 || [],
};
});
}

View File

@@ -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;
}
};

View File

@@ -1 +1,3 @@
export * from "./findings";
export * from "./findings-by-resource";
export * from "./findings-by-resource.adapter";

View File

@@ -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({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => drawer.openDrawer(row.index)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
@@ -271,6 +288,21 @@ export function FindingsGroupDrillDown({
onComplete={handleMuteComplete}
/>
)}
<ResourceDetailDrawer
open={drawer.isOpen}
onOpenChange={(open) => {
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}
/>
</FindingsSelectionContext.Provider>
);
}

View File

@@ -0,0 +1,2 @@
export { ResourceDetailDrawer } from "./resource-detail-drawer";
export { useResourceDetailDrawer } from "./use-resource-detail-drawer";

View File

@@ -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 }) => (
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
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 (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16">
<TreeSpinner className="size-6" />
<span className="text-text-neutral-tertiary text-sm">
Loading finding details...
</span>
</div>
);
}
if (!currentFinding) {
return (
<div className="flex flex-1 items-center justify-center py-16">
<p className="text-text-neutral-tertiary text-sm">
No finding data available for this resource.
</p>
</div>
);
}
const f = currentFinding;
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < totalResources - 1;
return (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
{!f.isMuted && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[f.id]}
onComplete={() => {
setIsMuteModalOpen(false);
onMuteComplete();
}}
/>
)}
{/* Header: status badges + title */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<StatusFindingBadge status={f.status as FindingStatus} />
<SeverityBadge severity={f.severity} />
<Muted
isMuted={f.isMuted}
mutedReason={f.mutedReason || "This finding is muted"}
/>
</div>
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{f.checkTitle}
</h2>
{f.complianceFrameworks.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-text-neutral-tertiary text-xs font-medium">
Compliance Frameworks:
</span>
<div className="flex items-center gap-1.5">
{f.complianceFrameworks.map((framework) => {
const icon = getComplianceIcon(framework);
return icon ? (
<Image
key={framework}
src={icon}
alt={framework}
width={20}
height={20}
className="shrink-0"
/>
) : (
<span
key={framework}
className="text-text-neutral-secondary text-xs"
>
{framework}
</span>
);
})}
</div>
</div>
)}
</div>
{/* Navigation: "Impacted Resource (X of N)" */}
<div className="flex items-center justify-between">
<Badge variant="tag" className="rounded text-sm">
Impacted Resource
<span className="font-bold">{currentIndex + 1}</span>
<span className="font-normal">of</span>
<span className="font-bold">{totalResources}</span>
</Badge>
<div className="flex items-center gap-1">
<button
type="button"
disabled={!hasPrev}
onClick={onNavigatePrev}
className="hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
aria-label="Previous resource"
>
<CircleChevronLeft className="size-5" />
</button>
<button
type="button"
disabled={!hasNext}
onClick={onNavigateNext}
className="hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
aria-label="Next resource"
>
<CircleChevronRight className="size-5" />
</button>
</div>
</div>
{/* Resource card */}
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
{/* Account, Resource, Service, Region, Actions */}
<div className="flex items-center justify-between">
<EntityInfo
cloudProvider={f.providerType}
entityAlias={f.providerAlias}
entityId={f.providerUid}
/>
<EntityInfo entityAlias={f.resourceName} entityId={f.resourceUid} />
<InfoField label="Service" inline>
{f.resourceService}
</InfoField>
<InfoField label="Region" inline>
{f.resourceRegion}
</InfoField>
<div>
<ActionDropdown ariaLabel="Resource actions">
<ActionDropdownItem
icon={
f.isMuted ? (
<VolumeOff className="size-5" />
) : (
<VolumeX className="size-5" />
)
}
label={f.isMuted ? "Muted" : "Mute"}
disabled={f.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
</ActionDropdown>
</div>
</div>
{/* Dates row */}
<div className="grid grid-cols-3 gap-4">
<InfoField label="Last detected" variant="simple">
<DateWithTime inline dateTime={f.updatedAt || "-"} />
</InfoField>
<InfoField label="First seen" variant="simple">
<DateWithTime inline dateTime={f.firstSeenAt || "-"} />
</InfoField>
<InfoField label="Failing for" variant="simple">
<span className="text-sm">
{getFailingForLabel(f.firstSeenAt) || "-"}
</span>
</InfoField>
</div>
{/* IDs row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Check ID" variant="simple">
<CodeSnippet value={f.checkId} className="max-w-full" />
</InfoField>
<InfoField label="Finding ID" variant="simple">
<CodeSnippet value={f.id} className="max-w-full" />
</InfoField>
<InfoField label="Finding UID" variant="simple">
<CodeSnippet value={f.uid} className="max-w-full" />
</InfoField>
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="flex min-h-0 w-full flex-1 flex-col">
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
<TabsTrigger value="other-findings">
Other Findings For This Resource
</TabsTrigger>
</TabsList>
</div>
{/* Finding Overview */}
<TabsContent value="overview" className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto">
{f.status === "FAIL" && f.risk && (
<InfoField label="Risk" variant="simple">
<div
className={cn(
"max-w-full rounded-md border p-2",
"border-border-error-primary bg-bg-fail-secondary",
)}
>
<MarkdownContainer>{f.risk}</MarkdownContainer>
</div>
</InfoField>
)}
{f.description && (
<InfoField label="Description">
<MarkdownContainer>{f.description}</MarkdownContainer>
</InfoField>
)}
{f.statusExtended && (
<InfoField label="Status Extended">{f.statusExtended}</InfoField>
)}
{f.remediation.recommendation.text && (
<InfoField label="Remediation">
<div className="flex flex-col gap-2">
<MarkdownContainer>
{f.remediation.recommendation.text}
</MarkdownContainer>
{f.remediation.recommendation.url && (
<CustomLink
href={f.remediation.recommendation.url}
size="sm"
>
Learn more
</CustomLink>
)}
</div>
</InfoField>
)}
{f.remediation.code.cli && (
<InfoField label="CLI Command" variant="simple">
<div className="bg-bg-neutral-tertiary rounded-md p-2">
<CodeSnippet
value={f.remediation.code.cli}
multiline
className="max-w-full border-0 bg-transparent p-0"
/>
</div>
</InfoField>
)}
{f.remediation.code.terraform && (
<InfoField label="Terraform" variant="simple">
<div className="bg-bg-neutral-tertiary rounded-md p-2">
<CodeSnippet
value={f.remediation.code.terraform}
multiline
className="max-w-full border-0 bg-transparent p-0"
/>
</div>
</InfoField>
)}
{f.remediation.code.nativeiac && (
<InfoField label="CloudFormation" variant="simple">
<div className="bg-bg-neutral-tertiary rounded-md p-2">
<CodeSnippet
value={f.remediation.code.nativeiac}
multiline
className="max-w-full border-0 bg-transparent p-0"
/>
</div>
</InfoField>
)}
{f.remediation.code.other && (
<InfoField label="Remediation Steps">
<MarkdownContainer>
{f.remediation.code.other}
</MarkdownContainer>
</InfoField>
)}
{f.additionalUrls.length > 0 && (
<InfoField label="References">
<ul className="list-inside list-disc space-y-1">
{f.additionalUrls.map((link, idx) => (
<li key={idx}>
<CustomLink
href={link}
size="sm"
className="break-all whitespace-normal!"
>
{link}
</CustomLink>
</li>
))}
</ul>
</InfoField>
)}
</TabsContent>
{/* Other Findings For This Resource */}
<TabsContent value="other-findings" className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto">
<div className="flex items-center justify-between">
<h4 className="text-text-neutral-primary text-sm font-medium">
Failed Findings For This Resource
</h4>
<span className="text-text-neutral-tertiary text-sm">
{otherFindings.length} Total Entries
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Status
</span>
</TableHead>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Finding
</span>
</TableHead>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Severity
</span>
</TableHead>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Time
</span>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{otherFindings.length > 0 ? (
otherFindings.map((finding) => (
<OtherFindingRow key={finding.id} finding={finding} />
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-16 text-center">
<span className="text-text-neutral-tertiary text-sm">
No other findings for this resource.
</span>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TabsContent>
</Tabs>
</div>
{/* Lighthouse AI button */}
<a
href="/lighthouse"
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-950 transition-opacity hover:opacity-90"
style={{
background: "linear-gradient(96deg, #2EE59B 3.55%, #62DFF0 98.85%)",
}}
>
<CircleArrowRight className="size-5" />
View This Finding With Lighthouse AI
</a>
</div>
);
}
function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
return (
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[finding.id]}
/>
<TableRow>
<TableCell className="w-10">
<NotificationIndicator isMuted={finding.isMuted} />
</TableCell>
<TableCell>
<StatusFindingBadge status={finding.status as FindingStatus} />
</TableCell>
<TableCell>
<p className="text-text-neutral-primary max-w-[300px] truncate text-sm">
{finding.checkTitle}
</p>
</TableCell>
<TableCell>
<SeverityBadge severity={finding.severity} />
</TableCell>
<TableCell>
<DateWithTime dateTime={finding.updatedAt} />
</TableCell>
<TableCell className="w-10">
<ActionDropdown ariaLabel="Finding actions">
<ActionDropdownItem
icon={
finding.isMuted ? (
<VolumeOff className="size-5" />
) : (
<VolumeX className="size-5" />
)
}
label={finding.isMuted ? "Muted" : "Mute"}
disabled={finding.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
</ActionDropdown>
</TableCell>
</TableRow>
</>
);
}

View File

@@ -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 (
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
<DrawerContent className="3xl:w-1/3 h-full w-full overflow-hidden p-6 outline-none md:w-1/2 md:max-w-none">
<DrawerHeader className="sr-only">
<DrawerTitle>Resource Finding Details</DrawerTitle>
<DrawerDescription>
View finding details for the selected resource
</DrawerDescription>
</DrawerHeader>
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DrawerClose>
{open && (
<ResourceDetailDrawerContent
isLoading={isLoading}
currentIndex={currentIndex}
totalResources={totalResources}
currentFinding={currentFinding}
otherFindings={otherFindings}
onNavigatePrev={onNavigatePrev}
onNavigateNext={onNavigateNext}
onMuteComplete={onMuteComplete}
/>
)}
</DrawerContent>
</Drawer>
);
}

View File

@@ -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<ResourceDrawerFinding[]>([]);
const cacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(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,
};
}