mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
fix(ui): resource drawer duplicates and performance optimization (#9921)
This commit is contained in:
@@ -34,7 +34,7 @@ export const ResourceDetailsSheet = ({
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleOpenChange}>
|
||||
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
|
||||
<SheetContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 pt-10 outline-none md:w-1/2 md:max-w-none">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="sr-only">Resource Details</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { AlertTriangle, Eye, MoreVertical } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -36,9 +35,6 @@ const getProviderData = (
|
||||
|
||||
// Component for resource name that opens the detail drawer
|
||||
const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const resourceId = searchParams.get("resourceId");
|
||||
const isOpen = resourceId === row.original.id;
|
||||
const resourceName = row.original.attributes?.name;
|
||||
const resourceUid = row.original.attributes?.uid;
|
||||
const displayName =
|
||||
@@ -46,11 +42,13 @@ const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
|
||||
? resourceName
|
||||
: "Unnamed resource";
|
||||
|
||||
// Note: We don't use defaultOpen here because ResourceDetailsSheet (rendered at page level)
|
||||
// already handles opening the drawer when resourceId is in the URL. Using defaultOpen={true}
|
||||
// here would cause duplicate drawers to render.
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ResourceDetail
|
||||
resourceDetails={row.original}
|
||||
defaultOpen={isOpen}
|
||||
trigger={
|
||||
<div className="max-w-[200px]">
|
||||
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
|
||||
|
||||
541
ui/components/resources/table/resource-detail-content.tsx
Normal file
541
ui/components/resources/table/resource-detail-content.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Check, Copy, ExternalLink, Link, Loader2 } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindingById, getLatestFindings } from "@/actions/findings";
|
||||
import { getResourceById } from "@/actions/resources";
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
import { FindingDetail } from "@/components/findings/table/finding-detail";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import {
|
||||
DateWithTime,
|
||||
getProviderLogo,
|
||||
InfoField,
|
||||
} from "@/components/ui/entities";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { buildGitFileUrl } from "@/lib/iac-utils";
|
||||
import {
|
||||
FindingProps,
|
||||
MetaDataProps,
|
||||
ProviderType,
|
||||
ResourceProps,
|
||||
} from "@/types";
|
||||
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
ResourceFinding,
|
||||
} from "./resource-findings-columns";
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
|
||||
const parseMetadata = (
|
||||
metadata: Record<string, unknown> | string | null | undefined,
|
||||
): Record<string, unknown> | null => {
|
||||
if (!metadata) return null;
|
||||
|
||||
if (typeof metadata === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// After the !metadata check above, metadata can only be object at this point
|
||||
// (null was already filtered, string was handled)
|
||||
if (typeof metadata === "object") {
|
||||
return metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildCustomBreadcrumbs = (
|
||||
_resourceName: string,
|
||||
findingTitle?: string,
|
||||
onBackToResource?: () => void,
|
||||
): CustomBreadcrumbItem[] => {
|
||||
const breadcrumbs: CustomBreadcrumbItem[] = [
|
||||
{
|
||||
name: "Resource Details",
|
||||
isClickable: !!findingTitle,
|
||||
onClick: findingTitle ? onBackToResource : undefined,
|
||||
isLast: !findingTitle,
|
||||
},
|
||||
];
|
||||
|
||||
if (findingTitle) {
|
||||
breadcrumbs.push({
|
||||
name: findingTitle,
|
||||
isLast: true,
|
||||
isClickable: false,
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
interface ResourceDetailContentProps {
|
||||
resourceDetails: ResourceProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heavy content component that contains all the state and data fetching logic.
|
||||
* This component should only be mounted when the drawer is actually open.
|
||||
*/
|
||||
export const ResourceDetailContent = ({
|
||||
resourceDetails,
|
||||
}: ResourceDetailContentProps) => {
|
||||
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
|
||||
const [findingsMetadata, setFindingsMetadata] =
|
||||
useState<MetaDataProps | null>(null);
|
||||
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
||||
const [findingsLoading, setFindingsLoading] = useState(true);
|
||||
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
|
||||
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
|
||||
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [metadataCopied, setMetadataCopied] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const findingFetchRef = useRef<AbortController | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const resource = resourceDetails;
|
||||
const resourceId = resource.id;
|
||||
const attributes = resource.attributes;
|
||||
const providerData = resource.relationships.provider.data.attributes;
|
||||
|
||||
// Cleanup abort controller on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
findingFetchRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyResourceUrl = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("resourceId", resourceId);
|
||||
const url = `${window.location.origin}${pathname}?${params.toString()}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
const copyMetadata = async (metadata: Record<string, unknown>) => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2));
|
||||
setMetadataCopied(true);
|
||||
setTimeout(() => setMetadataCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Load resource tags on mount
|
||||
useEffect(() => {
|
||||
const loadResourceTags = async () => {
|
||||
try {
|
||||
const resourceData = await getResourceById(resourceId, {
|
||||
fields: ["tags"],
|
||||
});
|
||||
if (resourceData?.data) {
|
||||
setResourceTags(resourceData.data.attributes.tags || {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading resource tags:", err);
|
||||
setResourceTags({});
|
||||
}
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
loadResourceTags();
|
||||
}
|
||||
}, [resourceId]);
|
||||
|
||||
// Load findings with server-side pagination and search
|
||||
useEffect(() => {
|
||||
const loadFindings = async () => {
|
||||
setFindingsLoading(true);
|
||||
|
||||
try {
|
||||
const findingsResponse = await getLatestFindings({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
query: searchQuery,
|
||||
sort: "severity,-inserted_at",
|
||||
filters: {
|
||||
"filter[resource_uid]": attributes.uid,
|
||||
"filter[status]": "FAIL",
|
||||
},
|
||||
});
|
||||
|
||||
if (findingsResponse?.data) {
|
||||
setFindingsMetadata(findingsResponse.meta || null);
|
||||
setFindingsData(findingsResponse.data as ResourceFinding[]);
|
||||
} else {
|
||||
setFindingsData([]);
|
||||
setFindingsMetadata(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading findings:", err);
|
||||
setFindingsData([]);
|
||||
setFindingsMetadata(null);
|
||||
} finally {
|
||||
setFindingsLoading(false);
|
||||
setHasInitiallyLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (attributes.uid) {
|
||||
loadFindings();
|
||||
}
|
||||
}, [attributes.uid, currentPage, pageSize, searchQuery, findingsReloadNonce]);
|
||||
|
||||
const navigateToFinding = async (findingId: string) => {
|
||||
if (findingFetchRef.current) {
|
||||
findingFetchRef.current.abort();
|
||||
}
|
||||
findingFetchRef.current = new AbortController();
|
||||
|
||||
setSelectedFindingId(findingId);
|
||||
setFindingDetailLoading(true);
|
||||
|
||||
try {
|
||||
const findingData = await getFindingById(
|
||||
findingId,
|
||||
"resources,scan.provider",
|
||||
);
|
||||
|
||||
if (findingFetchRef.current?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (findingData?.data) {
|
||||
const resourceDict = createDict("resources", findingData);
|
||||
const scanDict = createDict("scans", findingData);
|
||||
const providerDict = createDict("providers", findingData);
|
||||
|
||||
const finding = findingData.data;
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const foundResource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
const expandedFinding = {
|
||||
...finding,
|
||||
relationships: { scan, resource: foundResource, provider },
|
||||
};
|
||||
|
||||
setFindingDetails(expandedFinding);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("Error fetching finding:", error);
|
||||
} finally {
|
||||
if (!findingFetchRef.current?.signal.aborted) {
|
||||
setFindingDetailLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToResource = () => {
|
||||
setSelectedFindingId(null);
|
||||
setFindingDetails(null);
|
||||
setFindingDetailLoading(false);
|
||||
};
|
||||
|
||||
const handleMuteComplete = (_findingIds?: string[]) => {
|
||||
const ids =
|
||||
_findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds;
|
||||
|
||||
setRowSelection({});
|
||||
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const failedFindings = findingsData;
|
||||
|
||||
const selectableRowCount = failedFindings.filter(
|
||||
(f) => !f.attributes.muted,
|
||||
).length;
|
||||
|
||||
// Reset selection when page changes
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const totalFindings = findingsMetadata?.pagination?.count || 0;
|
||||
|
||||
const getRowCanSelect = (row: Row<ResourceFinding>): boolean =>
|
||||
!row.original.attributes.muted;
|
||||
|
||||
const selectedFindingIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => failedFindings[parseInt(idx)]?.id)
|
||||
.filter(Boolean);
|
||||
|
||||
const columns = getResourceFindingsColumns(
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
navigateToFinding,
|
||||
handleMuteComplete,
|
||||
);
|
||||
|
||||
const gitUrl =
|
||||
providerData.provider === "iac"
|
||||
? buildGitFileUrl(
|
||||
providerData.uid,
|
||||
attributes.name,
|
||||
"",
|
||||
attributes.region,
|
||||
)
|
||||
: null;
|
||||
|
||||
const findingTitle =
|
||||
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
|
||||
|
||||
// Content when viewing a finding detail (breadcrumb navigation)
|
||||
if (selectedFindingId) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BreadcrumbNavigation
|
||||
mode="custom"
|
||||
customItems={buildCustomBreadcrumbs(
|
||||
attributes.name,
|
||||
findingDetailLoading ? "Loading..." : findingTitle,
|
||||
handleBackToResource,
|
||||
)}
|
||||
/>
|
||||
|
||||
{findingDetailLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading finding details...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
findingDetails && <FindingDetail findingDetails={findingDetails} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main resource content
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
{getProviderLogo(providerData.provider as ProviderType)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{renderValue(attributes.name)}
|
||||
</h2>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={copyResourceUrl}
|
||||
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
|
||||
aria-label="Copy resource link to clipboard"
|
||||
>
|
||||
<Link size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy resource link to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
{providerData.provider === "iac" && gitUrl && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
View in Repository
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Go to Resource in the Repository
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
<span className="text-text-neutral-secondary mr-1">
|
||||
Last Updated:
|
||||
</span>
|
||||
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="findings">
|
||||
Findings {totalFindings > 0 && `(${totalFindings})`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<InfoField label="Resource UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||
</InfoField>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Name">{renderValue(attributes.name)}</InfoField>
|
||||
<InfoField label="Type">{renderValue(attributes.type)}</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Group">
|
||||
{attributes.groups && attributes.groups.length > 0
|
||||
? attributes.groups.map(getGroupLabel).join(", ")
|
||||
: "-"}
|
||||
</InfoField>
|
||||
<InfoField label="Service">
|
||||
{renderValue(attributes.service)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Region">
|
||||
{renderValue(attributes.region)}
|
||||
</InfoField>
|
||||
<InfoField label="Partition">
|
||||
{renderValue(attributes.partition)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<InfoField label="Details" variant="simple">
|
||||
{renderValue(attributes.details)}
|
||||
</InfoField>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime inline dateTime={attributes.inserted_at} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated">
|
||||
<DateWithTime inline dateTime={attributes.updated_at} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const parsedMetadata = parseMetadata(attributes.metadata);
|
||||
return parsedMetadata &&
|
||||
Object.entries(parsedMetadata).length > 0 ? (
|
||||
<InfoField label="Metadata" variant="simple">
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyMetadata(parsedMetadata)}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
|
||||
aria-label="Copy metadata to clipboard"
|
||||
>
|
||||
{metadataCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</InfoField>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{resourceTags && Object.entries(resourceTags).length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-text-neutral-secondary text-sm font-bold">
|
||||
Tags
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{Object.entries(resourceTags).map(([key, value]) => (
|
||||
<InfoField key={key} label={key}>
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* Findings Tab */}
|
||||
<TabsContent value="findings" className="flex flex-col gap-4">
|
||||
{findingsLoading && !hasInitiallyLoaded ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading findings...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={failedFindings}
|
||||
metadata={findingsMetadata ?? undefined}
|
||||
showSearch
|
||||
disableScroll
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
controlledSearch={searchQuery}
|
||||
onSearchChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
controlledPage={currentPage}
|
||||
controlledPageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={findingsLoading}
|
||||
/>
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Check, Copy, ExternalLink, Link, Loader2, X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getFindingById, getLatestFindings } from "@/actions/findings";
|
||||
import { getResourceById } from "@/actions/resources";
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
import { FindingDetail } from "@/components/findings/table/finding-detail";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -18,86 +12,10 @@ import {
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import {
|
||||
DateWithTime,
|
||||
getProviderLogo,
|
||||
InfoField,
|
||||
} from "@/components/ui/entities";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { buildGitFileUrl } from "@/lib/iac-utils";
|
||||
import {
|
||||
FindingProps,
|
||||
MetaDataProps,
|
||||
ProviderType,
|
||||
ResourceProps,
|
||||
} from "@/types";
|
||||
import { ResourceProps } from "@/types";
|
||||
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
ResourceFinding,
|
||||
} from "./resource-findings-columns";
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
|
||||
const parseMetadata = (
|
||||
metadata: Record<string, unknown> | string | null | undefined,
|
||||
): Record<string, unknown> | null => {
|
||||
if (!metadata) return null;
|
||||
|
||||
if (typeof metadata === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof metadata === "object" && metadata !== null) {
|
||||
return metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildCustomBreadcrumbs = (
|
||||
_resourceName: string,
|
||||
findingTitle?: string,
|
||||
onBackToResource?: () => void,
|
||||
): CustomBreadcrumbItem[] => {
|
||||
const breadcrumbs: CustomBreadcrumbItem[] = [
|
||||
{
|
||||
name: "Resource Details",
|
||||
isClickable: !!findingTitle,
|
||||
onClick: findingTitle ? onBackToResource : undefined,
|
||||
isLast: !findingTitle,
|
||||
},
|
||||
];
|
||||
|
||||
if (findingTitle) {
|
||||
breadcrumbs.push({
|
||||
name: findingTitle,
|
||||
isLast: true,
|
||||
isClickable: false,
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
import { ResourceDetailContent } from "./resource-detail-content";
|
||||
|
||||
interface ResourceDetailProps {
|
||||
resourceDetails: ResourceProps;
|
||||
@@ -107,6 +25,16 @@ interface ResourceDetailProps {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight wrapper component for resource details.
|
||||
*
|
||||
* When used with a trigger (table rows), this component only renders the Drawer shell
|
||||
* and trigger. The heavy ResourceDetailContent is only mounted when the drawer is open,
|
||||
* preventing unnecessary state initialization and data fetching for closed drawers.
|
||||
*
|
||||
* When used without a trigger (inline mode from ResourceDetailsSheet), it renders
|
||||
* the content directly since it's already visible.
|
||||
*/
|
||||
export const ResourceDetail = ({
|
||||
resourceDetails,
|
||||
trigger,
|
||||
@@ -114,499 +42,28 @@ export const ResourceDetail = ({
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: ResourceDetailProps) => {
|
||||
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
|
||||
const [findingsMetadata, setFindingsMetadata] =
|
||||
useState<MetaDataProps | null>(null);
|
||||
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
||||
const [findingsLoading, setFindingsLoading] = useState(true);
|
||||
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
|
||||
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
|
||||
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [metadataCopied, setMetadataCopied] = useState(false);
|
||||
// Track internal open state for uncontrolled drawer (when using trigger)
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
// Drawer-local pagination and search state (not in URL to avoid page re-renders)
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const findingFetchRef = useRef<AbortController | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Cleanup abort controller on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
findingFetchRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
// Determine actual open state
|
||||
const isOpen = controlledOpen ?? internalOpen;
|
||||
|
||||
// Determine if drawer is actually open:
|
||||
// - If controlled (open prop provided), use that
|
||||
// - If uncontrolled (using trigger), use internal state
|
||||
// - If no trigger (inline mode), always consider it "open"
|
||||
const isDrawerOpen = !trigger || (controlledOpen ?? internalOpen);
|
||||
|
||||
// Handle open state changes for uncontrolled mode
|
||||
// Handle open state changes
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setInternalOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
|
||||
// Reset all drawer state when closing
|
||||
if (!newOpen) {
|
||||
findingFetchRef.current?.abort();
|
||||
setActiveTab("overview");
|
||||
setSelectedFindingId(null);
|
||||
setFindingDetails(null);
|
||||
setFindingDetailLoading(false);
|
||||
setRowSelection({});
|
||||
setCurrentPage(1);
|
||||
setPageSize(10);
|
||||
setSearchQuery("");
|
||||
setHasInitiallyLoaded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resource = resourceDetails;
|
||||
const resourceId = resource.id;
|
||||
const attributes = resource.attributes;
|
||||
const providerData = resource.relationships.provider.data.attributes;
|
||||
|
||||
const copyResourceUrl = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("resourceId", resourceId);
|
||||
const url = `${window.location.origin}${pathname}?${params.toString()}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
const copyMetadata = async (metadata: Record<string, unknown>) => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2));
|
||||
setMetadataCopied(true);
|
||||
setTimeout(() => setMetadataCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Load resource tags (separate from findings) - only when drawer is open
|
||||
useEffect(() => {
|
||||
const loadResourceTags = async () => {
|
||||
try {
|
||||
const resourceData = await getResourceById(resourceId, {
|
||||
fields: ["tags"],
|
||||
});
|
||||
if (resourceData?.data) {
|
||||
setResourceTags(resourceData.data.attributes.tags || {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading resource tags:", err);
|
||||
setResourceTags({});
|
||||
}
|
||||
};
|
||||
|
||||
if (resourceId && isDrawerOpen) {
|
||||
loadResourceTags();
|
||||
}
|
||||
}, [resourceId, isDrawerOpen]);
|
||||
|
||||
// Load findings with server-side pagination and search - only when drawer is open
|
||||
useEffect(() => {
|
||||
const loadFindings = async () => {
|
||||
setFindingsLoading(true);
|
||||
|
||||
try {
|
||||
const findingsResponse = await getLatestFindings({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
query: searchQuery,
|
||||
sort: "severity,-inserted_at",
|
||||
filters: {
|
||||
"filter[resource_uid]": attributes.uid,
|
||||
"filter[status]": "FAIL",
|
||||
},
|
||||
});
|
||||
|
||||
if (findingsResponse?.data) {
|
||||
setFindingsMetadata(findingsResponse.meta || null);
|
||||
setFindingsData(findingsResponse.data as ResourceFinding[]);
|
||||
} else {
|
||||
setFindingsData([]);
|
||||
setFindingsMetadata(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading findings:", err);
|
||||
setFindingsData([]);
|
||||
setFindingsMetadata(null);
|
||||
} finally {
|
||||
setFindingsLoading(false);
|
||||
setHasInitiallyLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (attributes.uid && isDrawerOpen) {
|
||||
loadFindings();
|
||||
}
|
||||
}, [
|
||||
attributes.uid,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
isDrawerOpen,
|
||||
findingsReloadNonce,
|
||||
]);
|
||||
|
||||
const navigateToFinding = async (findingId: string) => {
|
||||
// Cancel any in-flight request
|
||||
if (findingFetchRef.current) {
|
||||
findingFetchRef.current.abort();
|
||||
}
|
||||
findingFetchRef.current = new AbortController();
|
||||
|
||||
setSelectedFindingId(findingId);
|
||||
setFindingDetailLoading(true);
|
||||
|
||||
try {
|
||||
const findingData = await getFindingById(
|
||||
findingId,
|
||||
"resources,scan.provider",
|
||||
);
|
||||
|
||||
// Check if request was aborted
|
||||
if (findingFetchRef.current?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (findingData?.data) {
|
||||
const resourceDict = createDict("resources", findingData);
|
||||
const scanDict = createDict("scans", findingData);
|
||||
const providerDict = createDict("providers", findingData);
|
||||
|
||||
const finding = findingData.data;
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const foundResource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
const expandedFinding = {
|
||||
...finding,
|
||||
relationships: { scan, resource: foundResource, provider },
|
||||
};
|
||||
|
||||
setFindingDetails(expandedFinding);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore abort errors
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("Error fetching finding:", error);
|
||||
} finally {
|
||||
// Only update loading state if this request wasn't aborted
|
||||
if (!findingFetchRef.current?.signal.aborted) {
|
||||
setFindingDetailLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToResource = () => {
|
||||
setSelectedFindingId(null);
|
||||
setFindingDetails(null);
|
||||
setFindingDetailLoading(false);
|
||||
};
|
||||
|
||||
const handleMuteComplete = (_findingIds?: string[]) => {
|
||||
const ids =
|
||||
_findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds;
|
||||
|
||||
setRowSelection({});
|
||||
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Findings are already filtered (FAIL only) and sorted by the server
|
||||
const failedFindings = findingsData;
|
||||
|
||||
const selectableRowCount = failedFindings.filter(
|
||||
(f) => !f.attributes.muted,
|
||||
).length;
|
||||
|
||||
// Reset selection when page changes
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
// Calculate total findings from metadata for tab title
|
||||
const totalFindings = findingsMetadata?.pagination?.count || 0;
|
||||
|
||||
const getRowCanSelect = (row: Row<ResourceFinding>): boolean =>
|
||||
!row.original.attributes.muted;
|
||||
|
||||
const selectedFindingIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => failedFindings[parseInt(idx)]?.id)
|
||||
.filter(Boolean);
|
||||
|
||||
const columns = getResourceFindingsColumns(
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
navigateToFinding,
|
||||
handleMuteComplete,
|
||||
);
|
||||
|
||||
// Build Git URL for IaC resources
|
||||
const gitUrl =
|
||||
providerData.provider === "iac"
|
||||
? buildGitFileUrl(
|
||||
providerData.uid,
|
||||
attributes.name,
|
||||
"",
|
||||
attributes.region,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Content when viewing a finding detail (breadcrumb navigation)
|
||||
const findingTitle =
|
||||
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
|
||||
|
||||
const findingContent = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BreadcrumbNavigation
|
||||
mode="custom"
|
||||
customItems={buildCustomBreadcrumbs(
|
||||
attributes.name,
|
||||
findingDetailLoading ? "Loading..." : findingTitle,
|
||||
handleBackToResource,
|
||||
)}
|
||||
/>
|
||||
|
||||
{findingDetailLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading finding details...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
findingDetails && <FindingDetail findingDetails={findingDetails} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Main resource content
|
||||
const resourceContent = (
|
||||
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Provider logo */}
|
||||
<div className="shrink-0">
|
||||
{getProviderLogo(providerData.provider as ProviderType)}
|
||||
</div>
|
||||
|
||||
{/* Details column */}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{/* Title with copy link and optional IaC link */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{renderValue(attributes.name)}
|
||||
</h2>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={copyResourceUrl}
|
||||
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
|
||||
aria-label="Copy resource link to clipboard"
|
||||
>
|
||||
<Link size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy resource link to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
{providerData.provider === "iac" && gitUrl && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
View in Repository
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Go to Resource in the Repository
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
<span className="text-text-neutral-secondary mr-1">
|
||||
Last Updated:
|
||||
</span>
|
||||
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="findings">
|
||||
Findings {totalFindings > 0 && `(${totalFindings})`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<InfoField label="Resource UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||
</InfoField>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Name">{renderValue(attributes.name)}</InfoField>
|
||||
<InfoField label="Type">{renderValue(attributes.type)}</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Group">
|
||||
{attributes.groups && attributes.groups.length > 0
|
||||
? attributes.groups.map(getGroupLabel).join(", ")
|
||||
: "-"}
|
||||
</InfoField>
|
||||
<InfoField label="Service">
|
||||
{renderValue(attributes.service)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Region">
|
||||
{renderValue(attributes.region)}
|
||||
</InfoField>
|
||||
<InfoField label="Partition">
|
||||
{renderValue(attributes.partition)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<InfoField label="Details" variant="simple">
|
||||
{renderValue(attributes.details)}
|
||||
</InfoField>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime inline dateTime={attributes.inserted_at} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated">
|
||||
<DateWithTime inline dateTime={attributes.updated_at} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const parsedMetadata = parseMetadata(attributes.metadata);
|
||||
return parsedMetadata &&
|
||||
Object.entries(parsedMetadata).length > 0 ? (
|
||||
<InfoField label="Metadata" variant="simple">
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyMetadata(parsedMetadata)}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
|
||||
aria-label="Copy metadata to clipboard"
|
||||
>
|
||||
{metadataCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</InfoField>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{resourceTags && Object.entries(resourceTags).length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-text-neutral-secondary text-sm font-bold">
|
||||
Tags
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{Object.entries(resourceTags).map(([key, value]) => (
|
||||
<InfoField key={key} label={key}>
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* Findings Tab */}
|
||||
<TabsContent value="findings" className="flex flex-col gap-4">
|
||||
{findingsLoading && !hasInitiallyLoaded ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading findings...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={failedFindings}
|
||||
metadata={findingsMetadata ?? undefined}
|
||||
showSearch
|
||||
disableScroll
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
controlledSearch={searchQuery}
|
||||
onSearchChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1); // Reset to first page on search
|
||||
}}
|
||||
controlledPage={currentPage}
|
||||
controlledPageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={findingsLoading}
|
||||
/>
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Determine which content to show
|
||||
const content = selectedFindingId ? findingContent : resourceContent;
|
||||
|
||||
// If no trigger, render content directly (inline mode)
|
||||
// If no trigger, render content directly (inline mode for ResourceDetailsSheet)
|
||||
if (!trigger) {
|
||||
return content;
|
||||
return <ResourceDetailContent resourceDetails={resourceDetails} />;
|
||||
}
|
||||
|
||||
// With trigger, wrap in Drawer
|
||||
// With trigger, wrap in Drawer - content only mounts when open (lazy loading)
|
||||
return (
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={controlledOpen ?? internalOpen}
|
||||
open={isOpen}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
@@ -620,7 +77,8 @@ export const ResourceDetail = ({
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{content}
|
||||
{/* Content only renders when drawer is open - this is the key optimization */}
|
||||
{isOpen && <ResourceDetailContent resourceDetails={resourceDetails} />}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user