mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
feat(ui): new resources side drawer with redesigned detail panel (#10673)
This commit is contained in:
@@ -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
-1
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user