feat(ui): new resources side drawer with redesigned detail panel (#10673)

This commit is contained in:
Alejandro Bailo
2026-04-14 17:20:19 +02:00
committed by GitHub
parent f3a042933f
commit 89d72cf8fd
20 changed files with 637 additions and 502 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.24.0] (Prowler UNRELEASED)
### 🚀 Added
- Resources side drawer with redesigned detail panel [(#10673)](https://github.com/prowler-cloud/prowler/pull/10673)
---
## [1.23.1] (Prowler UNRELEASED)
### 🐞 Fixed
@@ -1,7 +1,7 @@
"use client";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
+31
View File
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("resources page", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const tablePath = path.join(
currentDir,
"../../../components/resources/table/resources-table-with-selection.tsx",
);
const pageSource = readFileSync(pagePath, "utf8");
const tableSource = readFileSync(tablePath, "utf8");
it("fetches the deep-linked resource on the server in parallel with the rest of the page data", () => {
expect(pageSource).toContain("getResourceById(initialResourceId");
expect(pageSource).toContain("await Promise.all");
expect(pageSource).toContain("initialResource={processedResource}");
});
it("keeps the client table free of deep-link fetch effects", () => {
expect(tableSource).not.toContain("useEffect");
expect(tableSource).not.toContain("useRef");
expect(tableSource).not.toContain("getResourceById");
expect(tableSource).not.toContain("initialResourceId");
expect(tableSource).toContain("initialResource?: ResourceProps | null");
});
});
+17 -17
View File
@@ -8,7 +8,6 @@ import {
getResourceById,
getResources,
} from "@/actions/resources";
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
import { ResourcesTableWithSelection } from "@/components/resources/table";
@@ -36,8 +35,7 @@ export default async function Resources({
// Check if the searchParams contain any date or scan filter
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
// Check if there's a specific resource ID to fetch
const resourceId = resolvedSearchParams.resourceId?.toString();
const initialResourceId = resolvedSearchParams.resourceId?.toString();
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
[
@@ -47,35 +45,33 @@ export default async function Resources({
sort: encodedSort,
}),
getProviders({ pageSize: 50 }),
resourceId
? getResourceById(resourceId, { include: ["provider"] })
: Promise.resolve(null),
initialResourceId
? getResourceById(initialResourceId, { include: ["provider"] })
: Promise.resolve(undefined),
],
);
// Process the resource data to match the expected structure
const processedResource = resourceByIdData?.data
? (() => {
const resource = resourceByIdData.data;
const providerDict = createDict("providers", resourceByIdData);
const provider = {
data: providerDict[resource.relationships?.provider?.data?.id],
};
return {
...resource,
relationships: {
...resource.relationships,
provider,
provider: {
data: providerDict[resource.relationships.provider.data.id],
},
},
} as ResourceProps;
} satisfies ResourceProps;
})()
: null;
// Extract unique regions, services, groups from the metadata endpoint
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes = metadataInfoData?.data?.attributes?.types || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
return (
@@ -86,24 +82,27 @@ export default async function Resources({
providers={providersData?.data || []}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueGroups={uniqueGroups}
/>
</div>
<Suspense fallback={<SkeletonTableResources />}>
<SSRDataTable searchParams={resolvedSearchParams} />
<SSRDataTable
searchParams={resolvedSearchParams}
initialResource={processedResource}
/>
</Suspense>
</FilterTransitionWrapper>
{processedResource && (
<ResourceDetailsSheet resource={processedResource} />
)}
</ContentLayout>
);
}
const SSRDataTable = async ({
searchParams,
initialResource,
}: {
searchParams: SearchParamsProps;
initialResource?: ResourceProps | null;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
@@ -175,6 +174,7 @@ const SSRDataTable = async ({
<ResourcesTableWithSelection
data={expandedResources || []}
metadata={resourcesData?.meta}
initialResource={initialResource}
/>
</>
);
@@ -121,7 +121,9 @@ export const ProvidersFilters = ({
onValuesChange={(values) => pushDropdownFilter(filter, values)}
>
<MultiSelectTrigger size="default">
<MultiSelectValue placeholder={filter.labelCheckboxGroup} />
<MultiSelectValue
placeholder={`All ${filter.labelCheckboxGroup}`}
/>
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
@@ -1,48 +1,43 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { X } from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/shadcn";
import { ResourceProps } from "@/types";
import { ResourceDetail } from "./table/resource-detail";
import { ResourceDetailContent } from "./table/resource-detail-content";
interface ResourceDetailsSheetProps {
resource: ResourceProps;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ResourceDetailsSheet = ({
resource,
open,
onOpenChange,
}: ResourceDetailsSheetProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleOpenChange = (open: boolean) => {
if (!open) {
const params = new URLSearchParams(searchParams.toString());
params.delete("resourceId");
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}
};
return (
<Sheet open={true} onOpenChange={handleOpenChange}>
<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">
View the resource details
</SheetDescription>
</SheetHeader>
<ResourceDetail resourceDetails={resource} />
</SheetContent>
</Sheet>
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
<DrawerContent className="3xl:w-1/3 h-full w-full overflow-hidden p-6 outline-none md:w-1/2 md:max-w-none md:min-w-[720px]">
<DrawerHeader className="sr-only">
<DrawerTitle>Resource Details</DrawerTitle>
<DrawerDescription>View the resource details</DrawerDescription>
</DrawerHeader>
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DrawerClose>
{open && <ResourceDetailContent resourceDetails={resource} />}
</DrawerContent>
</Drawer>
);
};
+13 -10
View File
@@ -6,7 +6,6 @@ import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { CustomDatePicker } from "@/components/filters/custom-date-picker";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
@@ -17,6 +16,7 @@ interface ResourcesFiltersProps {
providers: ProviderProps[];
uniqueRegions: string[];
uniqueServices: string[];
uniqueResourceTypes: string[];
uniqueGroups: string[];
}
@@ -24,6 +24,7 @@ export const ResourcesFilters = ({
providers,
uniqueRegions,
uniqueServices,
uniqueResourceTypes,
uniqueGroups,
}: ResourcesFiltersProps) => {
const [isExpanded, setIsExpanded] = useState(false);
@@ -32,22 +33,28 @@ export const ResourcesFilters = ({
const customFilters = [
{
key: "region__in",
labelCheckboxGroup: "Region",
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 1,
},
{
key: "service__in",
labelCheckboxGroup: "Service",
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 2,
},
{
key: "type__in",
labelCheckboxGroup: "Types",
values: uniqueResourceTypes,
index: 3,
},
{
key: "groups__in",
labelCheckboxGroup: "Group",
labelCheckboxGroup: "Groups",
values: uniqueGroups,
labelFormatter: getGroupLabel,
index: 3,
index: 4,
},
];
@@ -81,11 +88,7 @@ export const ResourcesFilters = ({
{/* Expandable filters section */}
{hasCustomFilters && (
<ExpandableSection isExpanded={isExpanded}>
<DataTableFilterCustom
filters={customFilters}
prependElement={<CustomDatePicker />}
hideClearButton
/>
<DataTableFilterCustom filters={customFilters} hideClearButton />
</ExpandableSection>
)}
</div>
+166 -169
View File
@@ -1,21 +1,18 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { AlertTriangle, Eye } from "lucide-react";
import { useState } from "react";
import { AlertTriangle, Container, Eye } from "lucide-react";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { EntityInfo } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { getGroupLabel } from "@/lib/categories";
import { getRegionFlag } from "@/lib/region-flags";
import { ProviderType, ResourceProps } from "@/types";
import { ResourceDetail } from "./resource-detail";
const getResourceData = (
row: { original: ResourceProps },
field: keyof ResourceProps["attributes"],
@@ -33,31 +30,25 @@ const getProviderData = (
);
};
// Component for resource name that opens the detail drawer
const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
const resourceName = row.original.attributes?.name;
const resourceUid = row.original.attributes?.uid;
const displayName =
const entityAlias =
typeof resourceName === "string" && resourceName.trim().length > 0
? 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}
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">
{displayName}
</p>
</div>
<div className="max-w-[240px]">
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={entityAlias}
entityId={
resourceUid && typeof resourceUid === "string"
? resourceUid
: undefined
}
/>
{resourceUid && <CodeSnippet value={resourceUid} hideCode />}
</div>
);
};
@@ -80,163 +71,169 @@ const FailedFindingsBadge = ({ count }: { count: number }) => {
);
};
// Row actions dropdown
const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
interface GetColumnResourcesOptions {
onViewDetails: (resource: ResourceProps) => void;
}
return (
<>
<div className="flex items-center justify-end">
<ActionDropdown ariaLabel="Resource actions">
<ActionDropdownItem
icon={<Eye className="size-5" />}
label="View Details"
onSelect={() => setIsDrawerOpen(true)}
export function getColumnResources({
onViewDetails,
}: GetColumnResourcesOptions): ColumnDef<ResourceProps>[] {
return [
// Name column
{
accessorKey: "resourceName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => <ResourceNameCell row={row} />,
enableSorting: false,
},
// Provider Account column
{
accessorKey: "provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias && typeof alias === "string" ? alias : undefined}
entityId={uid && typeof uid === "string" ? uid : undefined}
/>
</ActionDropdown>
</div>
<ResourceDetail
resourceDetails={row.original}
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
trigger={<span className="hidden" />}
/>
</>
);
};
// Column definitions for resources table
export const ColumnResources: ColumnDef<ResourceProps>[] = [
// Name column
{
accessorKey: "resourceName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => <ResourceNameCell row={row} />,
enableSorting: false,
},
// Provider Account column
{
accessorKey: "provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Provider Account" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias && typeof alias === "string" ? alias : undefined}
entityId={uid && typeof uid === "string" ? uid : undefined}
/>
);
);
},
enableSorting: false,
},
enableSorting: false,
},
// Failed Findings column
{
accessorKey: "failedFindings",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Failed Findings" />
),
cell: ({ row }) => {
const failedFindingsCount = getResourceData(
row,
"failed_findings_count",
) as number;
// Failed Findings column
{
accessorKey: "failedFindings",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Failed Findings" />
),
cell: ({ row }) => {
const failedFindingsCount = getResourceData(
row,
"failed_findings_count",
) as number;
return <FailedFindingsBadge count={failedFindingsCount ?? 0} />;
return <FailedFindingsBadge count={failedFindingsCount ?? 0} />;
},
enableSorting: false,
},
enableSorting: false,
},
// Group column
{
accessorKey: "groups",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Group" param="groups" />
),
cell: ({ row }) => {
const groups = getResourceData(row, "groups") as string[] | null;
// Group column
{
accessorKey: "groups",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Group" param="groups" />
),
cell: ({ row }) => {
const groups = getResourceData(row, "groups") as string[] | null;
if (!groups || groups.length === 0) {
return <p className="text-text-neutral-primary text-sm">-</p>;
}
if (!groups || groups.length === 0) {
return <p className="text-text-neutral-primary text-sm">-</p>;
}
const displayLabel = getGroupLabel(groups[0]);
const extraCount = groups.length - 1;
const displayLabel = getGroupLabel(groups[0]);
const extraCount = groups.length - 1;
return (
<div className="flex items-center gap-1">
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{displayLabel}
return (
<div className="flex items-center gap-1">
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{displayLabel}
</p>
{extraCount > 0 && (
<span className="text-text-neutral-secondary text-xs">
+{extraCount}
</span>
)}
</div>
);
},
enableSorting: false,
},
// Type column
{
accessorKey: "type",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Type" param="type" />
),
cell: ({ row }) => {
const type = getResourceData(row, "type");
return (
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
{typeof type === "string" ? type : "-"}
</p>
{extraCount > 0 && (
<span className="text-text-neutral-secondary text-xs">
+{extraCount}
</span>
)}
);
},
},
// Region column
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" param="region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
const regionText = typeof region === "string" ? region : "-";
const regionFlag =
typeof region === "string" ? getRegionFlag(region) : "";
return (
<span className="text-text-neutral-primary flex max-w-[140px] items-center gap-1.5 truncate text-sm">
{regionFlag && (
<span className="translate-y-px text-base leading-none">
{regionFlag}
</span>
)}
<span className="truncate">{regionText}</span>
</span>
);
},
},
// Service column
{
accessorKey: "service",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Service"
param="service"
/>
),
cell: ({ row }) => {
const service = getResourceData(row, "service");
return (
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
{typeof service === "string" ? service : "-"}
</p>
);
},
},
// Actions column
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => (
<div
className="flex items-center justify-end"
onClick={(event) => event.stopPropagation()}
>
<ActionDropdown ariaLabel="Resource actions">
<ActionDropdownItem
icon={<Eye className="size-5" />}
label="View Details"
onSelect={() => onViewDetails(row.original)}
/>
</ActionDropdown>
</div>
);
),
enableSorting: false,
},
enableSorting: false,
},
// Type column
{
accessorKey: "type",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Type" param="type" />
),
cell: ({ row }) => {
const type = getResourceData(row, "type");
return (
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
{typeof type === "string" ? type : "-"}
</p>
);
},
},
// Region column
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" param="region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{typeof region === "string" ? region : "-"}
</p>
);
},
},
// Service column
{
accessorKey: "service",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Service" param="service" />
),
cell: ({ row }) => {
const service = getResourceData(row, "service");
return (
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
{typeof service === "string" ? service : "-"}
</p>
);
},
},
// Actions column
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => <ResourceRowActions row={row} />,
enableSorting: false,
},
];
];
}
@@ -1,15 +1,25 @@
"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 {
Check,
Container,
Copy,
CornerDownRight,
ExternalLink,
Link,
Loader2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { getFindingById, getLatestFindings } from "@/actions/findings";
import { listOrganizationsSafe } from "@/actions/organizations/organizations";
import { getResourceById } from "@/actions/resources";
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
import { FindingDetail } from "@/components/findings/table/finding-detail";
import {
Card,
Tabs,
TabsContent,
TabsList,
@@ -18,30 +28,61 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import {
InfoField,
InfoTooltip,
} from "@/components/shadcn/info-field/info-field";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import {
DateWithTime,
getProviderLogo,
InfoField,
} from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib";
import { getGroupLabel } from "@/lib/categories";
import { buildGitFileUrl } from "@/lib/iac-utils";
import { getRegionFlag } from "@/lib/region-flags";
import {
FindingProps,
MetaDataProps,
ProviderType,
ResourceProps,
} from "@/types";
import { OrganizationResource } from "@/types/organizations";
import {
getResourceFindingsColumns,
ResourceFinding,
} from "./resource-findings-columns";
function useProviderOrganization(
providerId: string,
providerType: string,
): OrganizationResource | null {
const [org, setOrg] = useState<OrganizationResource | null>(null);
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
useEffect(() => {
if (!isCloudEnv || providerType !== "aws") {
setOrg(null);
return;
}
const loadOrg = async () => {
const response = await listOrganizationsSafe();
const found = response.data.find((o: OrganizationResource) =>
o.relationships?.providers?.data?.some(
(p: { id: string }) => p.id === providerId,
),
);
setOrg(found ?? null);
};
loadOrg();
}, [isCloudEnv, providerType, providerId]);
return org;
}
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
@@ -120,24 +161,27 @@ export const ResourceDetailContent = ({
);
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeTab, setActiveTab] = useState("overview");
const [activeTab, setActiveTab] = useState("findings");
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;
const providerId = resource.relationships.provider.data.id;
const providerOrg = useProviderOrganization(
providerId,
providerData.provider,
);
// Reset to overview tab when switching resources
useEffect(() => {
setActiveTab("overview");
setActiveTab("findings");
}, [resourceId]);
// Cleanup abort controller on unmount
@@ -148,9 +192,7 @@ export const ResourceDetailContent = ({
}, []);
const copyResourceUrl = () => {
const params = new URLSearchParams(searchParams.toString());
params.set("resourceId", resourceId);
const url = `${window.location.origin}${pathname}?${params.toString()}`;
const url = `${window.location.origin}/resources?resourceId=${resourceId}`;
navigator.clipboard.writeText(url);
};
@@ -324,6 +366,21 @@ export const ResourceDetailContent = ({
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 parsedMetadata = parseMetadata(attributes.metadata);
const hasMetadata =
parsedMetadata !== null && Object.entries(parsedMetadata).length > 0;
const tagEntries = Object.entries(resourceTags);
const hasTags = tagEntries.length > 0;
// Content when viewing a finding detail (breadcrumb navigation)
if (selectedFindingId) {
@@ -354,17 +411,12 @@ export const ResourceDetailContent = ({
// 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 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">
{renderValue(attributes.name)}
{resourceName}
</h2>
<Tooltip>
<TooltipTrigger asChild>
@@ -398,159 +450,219 @@ export const ResourceDetailContent = ({
</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>
<TabsTrigger value="events">Events</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 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-1 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>
) : 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}
/>
)}
</>
<EntityInfo
cloudProvider={providerData.provider as ProviderType}
entityAlias={providerData.alias ?? undefined}
entityId={providerData.uid}
/>
)}
</TabsContent>
<div className={providerOrg ? "self-end" : undefined}>
<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>
{/* Events Tab */}
<TabsContent value="events" className="flex flex-col gap-4">
<EventsTimeline
resourceId={resourceId}
isAwsProvider={providerData.provider === "aws"}
/>
</TabsContent>
</Tabs>
<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">
<DateWithTime inline dateTime={attributes.inserted_at || "-"} />
</InfoField>
<InfoField label="Last Updated" variant="compact">
<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">Metadata</TabsTrigger>
<TabsTrigger value="tags">Tags</TabsTrigger>
<TabsTrigger value="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 ? (
<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>
<TabsContent value="metadata" className="flex flex-col gap-4">
{attributes.details && attributes.details.trim() !== "" && (
<Card variant="inner">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-sm font-semibold">
Details:
</span>
<p className="text-text-neutral-primary text-sm break-words whitespace-pre-wrap">
{attributes.details}
</p>
</div>
</Card>
)}
{hasMetadata && parsedMetadata && (
<Card variant="inner">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-sm font-semibold">
Metadata:
</span>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary 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>
</div>
</Card>
)}
{!attributes.details?.trim() && !hasMetadata && (
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
No metadata available for this resource.
</p>
)}
</TabsContent>
<TabsContent value="tags" className="flex flex-col gap-4">
{hasTags ? (
<Card variant="inner">
<div className="flex flex-col gap-3">
<span className="text-text-neutral-secondary text-sm font-semibold">
Tags:
</span>
<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>
</div>
</Card>
) : (
<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">
<EventsTimeline
resourceId={resourceId}
isAwsProvider={providerData.provider === "aws"}
/>
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
};
@@ -1,28 +1,56 @@
"use client";
import { useState } from "react";
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps, ResourceProps } from "@/types";
import { ColumnResources } from "./column-resources";
import { getColumnResources } from "./column-resources";
interface ResourcesTableWithSelectionProps {
data: ResourceProps[];
metadata?: MetaDataProps;
initialResource?: ResourceProps | null;
}
export function ResourcesTableWithSelection({
data,
metadata,
initialResource = null,
}: ResourcesTableWithSelectionProps) {
// Ensure data is always an array for safe operations
const safeData = data ?? [];
const [selectedResource, setSelectedResource] =
useState<ResourceProps | null>(initialResource);
const [drawerOpen, setDrawerOpen] = useState(Boolean(initialResource));
const openDrawer = (resource: ResourceProps) => {
setSelectedResource(resource);
setDrawerOpen(true);
};
const columns = getColumnResources({ onViewDetails: openDrawer });
return (
<DataTable
columns={ColumnResources}
data={safeData}
metadata={metadata}
showSearch
/>
<>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
showSearch
onRowClick={(row) => openDrawer(row.original)}
/>
{selectedResource && (
<ResourceDetailsSheet
resource={selectedResource}
open={drawerOpen}
onOpenChange={(open) => {
setDrawerOpen(open);
if (!open) setSelectedResource(null);
}}
/>
)}
</>
);
}
+2 -1
View File
@@ -1,8 +1,9 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime, EntityInfo, InfoField } from "@/components/ui/entities";
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
import { StatusBadge } from "@/components/ui/table/status-badge";
import { formatDuration } from "@/lib/date-utils";
import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types";
+14 -10
View File
@@ -7,6 +7,19 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip";
export function InfoTooltip({ content }: { content: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-pointer items-center">
<InfoIcon className="text-bg-data-info size-3" />
</span>
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>
);
}
export const INFO_FIELD_VARIANTS = {
default: "default",
simple: "simple",
@@ -38,16 +51,7 @@ export function InfoField({
<span className="flex items-center gap-1">
{label}
{inline && ":"}
{tooltipContent && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-pointer items-center">
<InfoIcon className="text-bg-data-info size-3" />
</span>
</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
)}
{tooltipContent && <InfoTooltip content={tooltipContent} />}
</span>
);
+10 -1
View File
@@ -368,6 +368,12 @@ export function MultiSelectSeparator({
className,
...props
}: ComponentPropsWithoutRef<typeof CommandSeparator>) {
const { selectedValues } = useMultiSelectContext();
if (selectedValues.size === 0) {
return null;
}
return (
<CommandSeparator
data-slot="multiselect-separator"
@@ -392,8 +398,11 @@ export function MultiSelectSelectAll({
const hasSelections = selectedValues.size > 0;
if (!hasSelections) {
return null;
}
const handleClearAll = () => {
// Clear all selections
onValuesChange?.([]);
};
-1
View File
@@ -1,5 +1,4 @@
export * from "./date-with-time";
export * from "./entity-info";
export * from "./get-provider-logo";
export * from "./info-field";
export * from "./scan-status";
-79
View File
@@ -1,79 +0,0 @@
import { Tooltip } from "@heroui/tooltip";
import clsx from "clsx";
import { InfoIcon } from "lucide-react";
interface InfoFieldProps {
label: string;
children: React.ReactNode;
variant?: "default" | "simple" | "transparent";
className?: string;
tooltipContent?: string;
inline?: boolean;
}
<Tooltip
className="text-xs"
content="Download a ZIP file that includes the JSON (OCSF), CSV, and HTML scan reports, along with the compliance report."
>
<div className="flex items-center gap-2">
<InfoIcon className="text-primary mb-1" size={12} />
</div>
</Tooltip>;
export const InfoField = ({
label,
children,
variant = "default",
tooltipContent,
className,
inline = false,
}: InfoFieldProps) => {
if (inline) {
return (
<div className={clsx("flex items-center gap-2", className)}>
<span className="text-text-neutral-tertiary text-xs font-bold">
<span className="flex items-center gap-1">
{label}:
{tooltipContent && (
<Tooltip className="text-xs" content={tooltipContent}>
<div className="flex cursor-pointer items-center gap-2">
<InfoIcon className="text-bg-data-info mb-1" size={12} />
</div>
</Tooltip>
)}
</span>
</span>
<div className="text-text-neutral-primary text-sm">{children}</div>
</div>
);
}
return (
<div className={clsx("flex flex-col gap-1", className)}>
<span className="text-text-neutral-tertiary text-xs font-bold">
<span className="flex items-center gap-1">
{label}
{tooltipContent && (
<Tooltip className="text-xs" content={tooltipContent}>
<div className="flex cursor-pointer items-center gap-2">
<InfoIcon className="text-bg-data-info mb-1" size={12} />
</div>
</Tooltip>
)}
</span>
</span>
{variant === "simple" ? (
<div className="text-text-neutral-primary text-sm break-all">
{children}
</div>
) : variant === "transparent" ? (
<div className="text-text-neutral-primary text-sm">{children}</div>
) : (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary rounded-lg border px-3 py-2 text-sm">
{children}
</div>
)}
</div>
);
};
@@ -166,7 +166,7 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
render(<DataTableFilterCustom filters={[severityFilter]} />);
// Then — renders without crashing
expect(screen.getByText("Severity")).toBeInTheDocument();
expect(screen.getByText("All Severity")).toBeInTheDocument();
});
});
@@ -185,7 +185,9 @@ export const DataTableFilterCustom = ({
onValuesChange={(values) => pushDropdownFilter(filter, values)}
>
<MultiSelectTrigger size="default">
<MultiSelectValue placeholder={filter.labelCheckboxGroup} />
<MultiSelectValue
placeholder={`All ${filter.labelCheckboxGroup}`}
/>
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
+22 -1
View File
@@ -108,6 +108,8 @@ interface DataTableProviderProps<TData, TValue> {
renderAfterRow?: (row: Row<TData>) => ReactNode;
/** Badge shown inside the search input (e.g., active drill-down group) */
searchBadge?: { label: string; onDismiss: () => void };
/** Optional click handler for top-level rows. */
onRowClick?: (row: Row<TData>) => void;
}
export function DataTable<TData, TValue>({
@@ -137,6 +139,7 @@ export function DataTable<TData, TValue>({
searchPlaceholder,
renderAfterRow,
searchBadge,
onRowClick,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -213,6 +216,18 @@ export function DataTable<TData, TValue>({
const rows = table.getRowModel().rows;
const handleRowClick = (row: Row<TData>, target: HTMLElement | null) => {
if (!onRowClick) {
return;
}
if (target?.closest("a, button, input, [role=menuitem]")) {
return;
}
onRowClick(row);
};
return (
<div
className={cn(
@@ -282,7 +297,13 @@ export function DataTable<TData, TValue>({
/>
) : (
<Fragment key={row.id}>
<TableRow data-state={row.getIsSelected() && "selected"}>
<TableRow
data-state={row.getIsSelected() && "selected"}
className={cn(onRowClick && "cursor-pointer")}
onClick={(event) =>
handleRowClick(row, event.target as HTMLElement)
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
@@ -3,8 +3,9 @@
import { useState } from "react";
import { Badge, Button, Card } from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Modal } from "@/components/shadcn/modal";
import { DateWithTime, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities";
import { EditTenantForm } from "@/components/users/forms";
import { DeleteTenantForm } from "@/components/users/forms/delete-tenant-form";
import { SwitchTenantForm } from "@/components/users/forms/switch-tenant-form";
@@ -4,8 +4,9 @@ import { Divider } from "@heroui/divider";
import { ProwlerShort } from "@/components/icons";
import { Card, CardContent } from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities";
import { UserDataWithRoles } from "@/types/users";
const TenantIdCopy = ({ id }: { id: string }) => {