mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
fix(ui): resource drawer duplicates and performance optimization (#9921)
This commit is contained in:
@@ -34,7 +34,7 @@ export const ResourceDetailsSheet = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={true} onOpenChange={handleOpenChange}>
|
<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>
|
<SheetHeader>
|
||||||
<SheetTitle className="sr-only">Resource Details</SheetTitle>
|
<SheetTitle className="sr-only">Resource Details</SheetTitle>
|
||||||
<SheetDescription className="sr-only">
|
<SheetDescription className="sr-only">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { AlertTriangle, Eye, MoreVertical } from "lucide-react";
|
import { AlertTriangle, Eye, MoreVertical } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,9 +35,6 @@ const getProviderData = (
|
|||||||
|
|
||||||
// Component for resource name that opens the detail drawer
|
// Component for resource name that opens the detail drawer
|
||||||
const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
|
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 resourceName = row.original.attributes?.name;
|
||||||
const resourceUid = row.original.attributes?.uid;
|
const resourceUid = row.original.attributes?.uid;
|
||||||
const displayName =
|
const displayName =
|
||||||
@@ -46,11 +42,13 @@ const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
|
|||||||
? resourceName
|
? resourceName
|
||||||
: "Unnamed resource";
|
: "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 (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ResourceDetail
|
<ResourceDetail
|
||||||
resourceDetails={row.original}
|
resourceDetails={row.original}
|
||||||
defaultOpen={isOpen}
|
|
||||||
trigger={
|
trigger={
|
||||||
<div className="max-w-[200px]">
|
<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">
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
import { X } from "lucide-react";
|
||||||
import { Check, Copy, ExternalLink, Link, Loader2, X } from "lucide-react";
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import type { ReactNode } from "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 {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -18,86 +12,10 @@ import {
|
|||||||
DrawerHeader,
|
DrawerHeader,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/shadcn";
|
} from "@/components/shadcn";
|
||||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
import { ResourceProps } from "@/types";
|
||||||
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 {
|
import { ResourceDetailContent } from "./resource-detail-content";
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ResourceDetailProps {
|
interface ResourceDetailProps {
|
||||||
resourceDetails: ResourceProps;
|
resourceDetails: ResourceProps;
|
||||||
@@ -107,6 +25,16 @@ interface ResourceDetailProps {
|
|||||||
onOpenChange?: (open: boolean) => void;
|
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 = ({
|
export const ResourceDetail = ({
|
||||||
resourceDetails,
|
resourceDetails,
|
||||||
trigger,
|
trigger,
|
||||||
@@ -114,499 +42,28 @@ export const ResourceDetail = ({
|
|||||||
defaultOpen = false,
|
defaultOpen = false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ResourceDetailProps) => {
|
}: 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)
|
// Track internal open state for uncontrolled drawer (when using trigger)
|
||||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
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
|
// Determine actual open state
|
||||||
useEffect(() => {
|
const isOpen = controlledOpen ?? internalOpen;
|
||||||
return () => {
|
|
||||||
findingFetchRef.current?.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Determine if drawer is actually open:
|
// Handle open state changes
|
||||||
// - 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
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
setInternalOpen(newOpen);
|
setInternalOpen(newOpen);
|
||||||
onOpenChange?.(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;
|
// If no trigger, render content directly (inline mode for ResourceDetailsSheet)
|
||||||
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 (!trigger) {
|
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 (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
direction="right"
|
direction="right"
|
||||||
open={controlledOpen ?? internalOpen}
|
open={isOpen}
|
||||||
defaultOpen={defaultOpen}
|
defaultOpen={defaultOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
>
|
>
|
||||||
@@ -620,7 +77,8 @@ export const ResourceDetail = ({
|
|||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DrawerClose>
|
</DrawerClose>
|
||||||
{content}
|
{/* Content only renders when drawer is open - this is the key optimization */}
|
||||||
|
{isOpen && <ResourceDetailContent resourceDetails={resourceDetails} />}
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user