Files
prowler/ui/components/resources/table/resource-detail-content.tsx
T

462 lines
15 KiB
TypeScript

"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { Container, CornerDownRight, Link } from "lucide-react";
import { useState } from "react";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { FloatingSelectionActions } from "@/components/findings/floating-selection-actions";
import { FindingDetailDrawer } from "@/components/findings/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
BreadcrumbNavigation,
CustomBreadcrumbItem,
} from "@/components/shadcn";
import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { EntityInfo } from "@/components/shadcn/entities/entity-info";
import {
InfoField,
InfoTooltip,
} from "@/components/shadcn/info-field/info-field";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { DataTable } from "@/components/shadcn/table";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import { ExternalResourceLink } from "@/components/shared/external-resource-link";
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
import { getGroupLabel } from "@/lib/categories";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { getRegionFlag } from "@/lib/region-flags";
import { ProviderType, ResourceProps } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import {
getResourceFindingsColumns,
ResourceFinding,
} from "./resource-findings-columns";
import { useFindingDetails } from "./use-finding-details";
import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
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 [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
null,
);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeTab, setActiveTab] = useState("findings");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
const resource = resourceDetails;
const resourceId = resource.id;
const attributes = resource.attributes;
const providerData = resource.relationships.provider.data.attributes;
const providerId = resource.relationships.provider.data.id;
const {
findingsData,
findingsMetadata,
findingsLoading,
hasInitiallyLoaded,
providerOrg,
resourceTags,
patchTriageUpdate,
} = useResourceDrawerBootstrap({
resourceId,
resourceUid: attributes.uid,
providerId,
providerType: providerData.provider,
currentPage,
pageSize,
searchQuery,
findingsReloadNonce,
});
const {
findingDetails,
findingDetailLoading,
navigateToFinding: loadFindingDetails,
resetFindingDetails,
} = useFindingDetails();
const copyResourceUrl = () => {
const url = `${window.location.origin}/resources?resourceId=${resourceId}`;
navigator.clipboard.writeText(url);
};
const navigateToFinding = async (findingId: string) => {
setSelectedFindingId(findingId);
await loadFindingDetails(findingId);
};
const handleBackToResource = () => {
setSelectedFindingId(null);
resetFindingDetails();
};
const handleMuteComplete = (_findingIds?: string[]) => {
const ids =
_findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds;
setRowSelection({});
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
};
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
await updateFindingTriage(input);
if (shouldRefreshAfterTriageUpdate(input)) {
setFindingsReloadNonce((value) => value + 1);
return;
}
patchTriageUpdate(input);
};
const failedFindings = findingsData;
const selectableRowCount = failedFindings.filter(
(f) => !f.attributes.muted,
).length;
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,
handleTriageUpdate,
loadLatestFindingTriageNote,
);
const findingTitle =
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
const resourceName =
typeof attributes.name === "string" && attributes.name.trim().length > 0
? attributes.name
: "Unnamed resource";
const resourceRegion = renderValue(attributes.region);
const regionFlag = getRegionFlag(resourceRegion);
const groupValue =
attributes.groups && attributes.groups.length > 0
? attributes.groups.map(getGroupLabel).join(", ")
: "-";
const tagEntries = Object.entries(resourceTags);
const hasTags = tagEntries.length > 0;
// 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 ? (
<LoadingState label="Loading finding details..." />
) : (
findingDetails && (
<FindingDetailDrawer
key={findingDetails.id}
finding={findingDetails}
inline
onMuteComplete={handleMuteComplete}
/>
)
)}
</div>
);
}
// Main resource content
return (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<div className="flex flex-col gap-2">
<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">
{resourceName}
</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>
</div>
<ExternalResourceLink
providerType={providerData.provider}
resourceUid={attributes.uid}
providerUid={providerData.uid}
resourceName={attributes.name}
region={attributes.region}
className="justify-start self-start"
/>
</div>
</div>
<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">
<div className="grid min-w-0 grid-cols-2 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
{providerOrg ? (
<div className="col-span-2 flex flex-col gap-1">
<EntityInfo
cloudProvider="aws"
entityAlias={providerOrg.attributes.name}
entityId={providerOrg.attributes.external_id}
/>
<div className="flex items-start pl-6">
<CornerDownRight className="text-text-neutral-tertiary mt-1 mr-2 size-4 shrink-0" />
<EntityInfo
cloudProvider="aws"
entityAlias={providerData.alias ?? undefined}
entityId={providerData.uid}
/>
</div>
</div>
) : (
<div className="col-span-2 md:col-span-1">
<EntityInfo
cloudProvider={providerData.provider as ProviderType}
entityAlias={providerData.alias ?? undefined}
entityId={providerData.uid}
/>
</div>
)}
<div
className={
providerOrg
? "col-span-2 self-end md:col-span-1"
: "col-span-2 md:col-span-1"
}
>
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={resourceName}
entityId={attributes.uid}
/>
</div>
<InfoField
label="Service"
variant="compact"
className={providerOrg ? "self-end" : undefined}
>
{renderValue(attributes.service)}
</InfoField>
<InfoField label="Region" variant="compact">
<span className="flex items-center gap-1.5">
{regionFlag && (
<span className="translate-y-px text-base leading-none">
{regionFlag}
</span>
)}
{resourceRegion}
</span>
</InfoField>
<InfoField label="Type" variant="compact">
{renderValue(attributes.type)}
</InfoField>
<InfoField label="Group" variant="compact">
{groupValue}
</InfoField>
<InfoField label="Partition" variant="compact">
{renderValue(attributes.partition)}
</InfoField>
<InfoField
label="Created At"
variant="compact"
className="col-start-1 min-w-0"
>
<DateWithTime inline dateTime={attributes.inserted_at || "-"} />
</InfoField>
<InfoField
label="Last Updated"
variant="compact"
className="col-start-2 min-w-0"
>
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
</InfoField>
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="mt-2 flex min-h-0 w-full flex-1 flex-col"
>
<div className="mb-4 flex shrink-0 items-center justify-between">
<TabsList>
<TabsTrigger value="findings">
<span className="flex items-center gap-1">
Findings {totalFindings > 0 && `(${totalFindings})`}
<InfoTooltip content="This table also includes muted findings" />
</span>
</TabsTrigger>
<TabsTrigger value="metadata" tooltip="Resource Metadata">
Evidence
</TabsTrigger>
<TabsTrigger value="tags" tooltip="Tags">
Tags
</TabsTrigger>
<TabsTrigger value="events" tooltip="Events">
Events
</TabsTrigger>
</TabsList>
</div>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent value="findings" className="flex flex-col gap-4">
{findingsLoading && !hasInitiallyLoaded ? (
<LoadingState label="Loading findings..." />
) : (
<>
<DataTable
columns={columns}
data={failedFindings}
metadata={findingsMetadata ?? undefined}
showSearch
disableScroll
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
controlledSearch={searchQuery}
onSearchChange={(value) => {
setRowSelection({});
setSearchQuery(value);
setCurrentPage(1);
}}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={(page) => {
setRowSelection({});
setCurrentPage(page);
}}
onPageSizeChange={(size) => {
setRowSelection({});
setCurrentPage(1);
setPageSize(size);
}}
isLoading={findingsLoading}
/>
{selectedFindingIds.length > 0 && (
<FloatingSelectionActions
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
}`}
onComplete={handleMuteComplete}
/>
)}
</>
)}
</TabsContent>
<TabsContent
value="metadata"
className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
>
<ResourceMetadataPanel
metadata={attributes.metadata}
details={attributes.details}
/>
</TabsContent>
<TabsContent value="tags" className="flex flex-col gap-4">
{hasTags ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{tagEntries.map(([key, value]) => (
<InfoField key={key} label={key} variant="compact">
{renderValue(value)}
</InfoField>
))}
</div>
) : (
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
No tags available for this resource.
</p>
)}
</TabsContent>
<TabsContent value="events" className="flex flex-col gap-4">
{activeTab === "events" && (
<EventsTimeline
resourceId={resourceId}
isAwsProvider={providerData.provider === "aws"}
/>
)}
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
};