feat(ui): Add resources view as inventory (#7760)

Co-authored-by: sumit_chaturvedi <chaturvedi.sumit@tftus.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pablo Lara
2025-07-17 10:01:05 +02:00
committed by GitHub
parent 087e01cc4f
commit d004a0c931
26 changed files with 1504 additions and 127 deletions

View File

@@ -8,6 +8,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Mutelist configuration form [(#8190)](https://github.com/prowler-cloud/prowler/pull/8190)
- SAML login integration [(#8203)](https://github.com/prowler-cloud/prowler/pull/8203)
- Resource view [(#7760)](https://github.com/prowler-cloud/prowler/pull/7760)
- Navigation link in Scans view to access Compliance Overview [(#8251)](https://github.com/prowler-cloud/prowler/pull/8251)
- Status column for findings table in the Compliance Detail view [(#8244)](https://github.com/prowler-cloud/prowler/pull/8244)
- Allow to restrict routes access based on user permissions [(#8287)](https://github.com/prowler-cloud/prowler/pull/8287)

View File

@@ -168,3 +168,24 @@ export const getLatestMetadataInfo = async ({
return undefined;
}
};
export const getFindingById = async (findingId: string, include = "") => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings/${findingId}`);
if (include) url.searchParams.append("include", include);
try {
const finding = await fetch(url.toString(), {
headers,
});
const data = await finding.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
console.error("Error fetching finding by ID:", error);
return undefined;
}
};

View File

@@ -0,0 +1,7 @@
export {
getLatestMetadataInfo,
getLatestResources,
getMetadataInfo,
getResourceById,
getResources,
} from "./resources";

View File

@@ -0,0 +1,216 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
export const getResources = async ({
page = 1,
query = "",
sort = "",
filters = {},
pageSize = 10,
include = "",
fields = [],
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string>;
pageSize?: number;
include?: string;
fields?: string[];
}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("resources");
const url = new URL(`${apiBaseUrl}/resources`);
if (fields.length > 0) {
url.searchParams.append("fields[resources]", fields.join(","));
}
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
if (include) url.searchParams.append("include", include);
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
try {
const resources = await fetch(url.toString(), {
headers,
});
const data = await resources.json();
const parsedData = parseStringify(data);
revalidatePath("/resources");
return parsedData;
} catch (error) {
console.error("Error fetching resources:", error);
return undefined;
}
};
export const getLatestResources = async ({
page = 1,
query = "",
sort = "",
include = "",
filters = {},
pageSize = 10,
fields = [],
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string>;
pageSize?: number;
include?: string;
fields?: string[];
}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("resources");
const url = new URL(`${apiBaseUrl}/resources/latest`);
if (fields.length > 0) {
url.searchParams.append("fields[resources]", fields.join(","));
}
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
if (include) url.searchParams.append("include", include);
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
try {
const resources = await fetch(url.toString(), {
headers,
});
const data = await resources.json();
const parsedData = parseStringify(data);
revalidatePath("/resources");
return parsedData;
} catch (error) {
console.error("Error fetching latest resources:", error);
return undefined;
}
};
export const getMetadataInfo = async ({
query = "",
sort = "",
filters = {},
}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/resources/metadata`);
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
try {
const metadata = await fetch(url.toString(), {
headers,
});
const data = await metadata.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching metadata info:", error);
return undefined;
}
};
export const getLatestMetadataInfo = async ({
query = "",
sort = "",
filters = {},
}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/resources/metadata/latest`);
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
try {
const metadata = await fetch(url.toString(), {
headers,
});
const data = await metadata.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
console.error("Error fetching latest metadata info:", error);
return undefined;
}
};
export const getResourceById = async (
id: string,
{
fields = [],
include = [],
}: {
fields?: string[];
include?: string[];
} = {},
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/resources/${id}`);
if (fields.length > 0) {
url.searchParams.append("fields[resources]", fields.join(","));
}
if (include.length > 0) {
url.searchParams.append("include", include.join(","));
}
try {
const resource = await fetch(url.toString(), {
headers,
});
if (!resource.ok) {
throw new Error(`Error fetching resource: ${resource.status}`);
}
const data = await resource.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
console.error("Error fetching resource by ID:", error);
return undefined;
}
};

View File

@@ -36,18 +36,14 @@ export const getScans = async ({
url.searchParams.append(`fields[${key}]`, String(value));
});
// Handle multiple filters
// Add dynamic filters (e.g., "filter[state]", "fields[scans]")
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
url.searchParams.append(key, String(value));
});
try {
const scans = await fetch(url.toString(), {
headers,
});
const data = await scans.json();
const response = await fetch(url.toString(), { headers });
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/scans");
return parsedData;

View File

@@ -32,6 +32,8 @@ interface ComplianceDetailSearchParams {
scanData?: string;
"filter[region__in]"?: string;
"filter[cis_profile_level]"?: string;
page?: string;
pageSize?: string;
}
const ComplianceIconSmall = ({
@@ -79,8 +81,13 @@ export default async function ComplianceDetail({
const cisProfileFilter = searchParams["filter[cis_profile_level]"];
const logoPath = getComplianceIcon(compliancetitle);
// Create a key that includes region filter for Suspense
const searchParamsKey = JSON.stringify(searchParams || {});
// Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination
const paramsForKey = Object.fromEntries(
Object.entries(searchParams).filter(
([key]) => key !== "page" && key !== "pageSize",
),
);
const searchParamsKey = JSON.stringify(paramsForKey);
const formattedTitle = compliancetitle.split("-").join(" ");
const pageTitle = version

View File

@@ -0,0 +1,155 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import {
getLatestMetadataInfo,
getLatestResources,
getMetadataInfo,
getResources,
} from "@/actions/resources";
import { FilterControls } from "@/components/filters";
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
import { ColumnResources } from "@/components/resources/table/column-resources";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import {
createDict,
extractFiltersAndQuery,
extractSortAndKey,
hasDateOrScanFilter,
replaceFieldKey,
} from "@/lib";
import { ResourceProps, SearchParamsProps } from "@/types";
export default async function Resources({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const { searchParamsKey, encodedSort } = extractSortAndKey(searchParams);
const { filters, query } = extractFiltersAndQuery(searchParams);
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
// Check if the searchParams contain any date or scan filter
const hasDateOrScan = hasDateOrScanFilter(searchParams);
const metadataInfoData = await (
hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo
)({
query,
filters: outputFilters,
sort: encodedSort,
});
// Extract unique regions, services, types, and names from the metadata endpoint
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes = metadataInfoData?.data?.attributes?.types || [];
return (
<ContentLayout title="Resources" icon="carbon:data-view">
<FilterControls search date />
<DataTableFilterCustom
filters={[
{
key: "region",
labelCheckboxGroup: "Region",
values: uniqueRegions,
},
{
key: "type",
labelCheckboxGroup: "Type",
values: uniqueResourceTypes,
},
{
key: "service",
labelCheckboxGroup: "Service",
values: uniqueServices,
},
]}
defaultOpen={true}
/>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
</ContentLayout>
);
}
const SSRDataTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const { encodedSort } = extractSortAndKey({
...searchParams,
...(searchParams.sort && { sort: searchParams.sort }),
});
const { filters, query } = extractFiltersAndQuery(searchParams);
// Check if the searchParams contain any date or scan filter
const hasDateOrScan = hasDateOrScanFilter(searchParams);
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
const fetchResources = hasDateOrScan ? getResources : getLatestResources;
const resourcesData = await fetchResources({
query,
page,
sort: encodedSort,
filters: outputFilters,
pageSize,
include: "provider",
fields: [
"name",
"failed_findings_count",
"region",
"service",
"type",
"provider",
"inserted_at",
"updated_at",
"uid",
],
});
// Create dictionary for providers (removed findings dict since we're not including findings anymore)
const providerDict = createDict("providers", resourcesData);
// Expand each resource with its corresponding provider (removed findings expansion)
const expandedResources = resourcesData?.data
? resourcesData.data.map((resource: ResourceProps) => {
const provider = {
data: providerDict[resource.relationships.provider.data.id],
};
return {
...resource,
relationships: {
...resource.relationships,
provider,
},
};
})
: [];
return (
<>
{resourcesData?.errors && (
<div className="mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-small text-red-700">
<p className="mr-2 font-semibold">Error:</p>
<p>{resourcesData.errors[0].detail}</p>
</div>
)}
<DataTable
columns={ColumnResources}
data={expandedResources || []}
metadata={resourcesData?.meta}
/>
</>
);
};

View File

@@ -4,6 +4,7 @@ import { Snippet } from "@nextui-org/react";
import Link from "next/link";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomSection } from "@/components/ui/custom";
import { EntityInfoShort, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
@@ -16,21 +17,6 @@ const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
const Section = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<div className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
<h3 className="text-md font-medium text-gray-800 dark:text-prowler-theme-pale/90">
{title}
</h3>
{children}
</div>
);
// Add new utility function for duration formatting
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
@@ -87,7 +73,7 @@ export const FindingDetail = ({
</div>
{/* Check Metadata */}
<Section title="Finding Details">
<CustomSection title="Finding Details">
<div className="flex flex-wrap gap-4">
<EntityInfoShort
cloudProvider={providerDetails.provider as ProviderType}
@@ -208,10 +194,10 @@ export const FindingDetail = ({
<InfoField label="Categories">
{attributes.check_metadata.categories?.join(", ") || "-"}
</InfoField>
</Section>
</CustomSection>
{/* Resource Details */}
<Section title="Resource Details">
<CustomSection title="Resource Details">
<InfoField label="Resource ID" variant="simple">
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800" hideSymbol>
<span className="whitespace-pre-line text-xs">
@@ -257,10 +243,10 @@ export const FindingDetail = ({
<DateWithTime inline dateTime={resource.updated_at || "-"} />
</InfoField>
</div>
</Section>
</CustomSection>
{/* Add new Scan Details section */}
<Section title="Scan Details">
<CustomSection title="Scan Details">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Scan Name">{scan.name || "N/A"}</InfoField>
<InfoField label="Resources Scanned">
@@ -296,7 +282,7 @@ export const FindingDetail = ({
</InfoField>
)}
</div>
</Section>
</CustomSection>
</div>
);
};

View File

@@ -0,0 +1,76 @@
import React from "react";
export const SkeletonFindingDetails = () => {
return (
<div className="flex animate-pulse flex-col gap-6 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
{/* Header */}
<div className="flex items-center justify-between">
<div className="h-6 w-2/3 rounded bg-default-200" />
<div className="flex items-center gap-x-4">
<div className="h-5 w-6 rounded-full bg-default-200" />
<div className="h-6 w-20 rounded bg-default-200" />
</div>
</div>
{/* Metadata Section */}
<div className="flex flex-wrap gap-4">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex flex-col gap-1">
<div className="h-4 w-20 rounded bg-default-200" />
<div className="h-5 w-40 rounded bg-default-200" />
</div>
))}
</div>
{/* InfoField Blocks */}
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex flex-col gap-2">
<div className="h-4 w-28 rounded bg-default-200" />
<div className="h-5 w-full rounded bg-default-200" />
</div>
))}
{/* Risk and Description Sections */}
<div className="flex flex-col gap-2">
<div className="h-4 w-28 rounded bg-default-200" />
<div className="h-16 w-full rounded bg-default-200" />
</div>
<div className="h-4 w-36 rounded bg-default-200" />
<div className="flex flex-col gap-2">
<div className="h-4 w-24 rounded bg-default-200" />
<div className="h-5 w-2/3 rounded bg-default-200" />
<div className="h-4 w-24 rounded bg-default-200" />
</div>
<div className="flex flex-col gap-2">
<div className="h-4 w-28 rounded bg-default-200" />
<div className="h-10 w-full rounded bg-default-200" />
</div>
{/* Additional Resources */}
<div className="flex flex-col gap-2">
<div className="h-4 w-36 rounded bg-default-200" />
<div className="h-5 w-32 rounded bg-default-200" />
</div>
{/* Categories */}
<div className="flex flex-col gap-2">
<div className="h-4 w-24 rounded bg-default-200" />
<div className="h-5 w-1/3 rounded bg-default-200" />
</div>
{/* Provider Info Section */}
<div className="mt-4 flex items-center gap-2">
<div className="relative h-8 w-8 rounded-full bg-default-200">
<div className="absolute right-0 top-0 h-2 w-2 rounded-full bg-default-300" />
</div>
<div className="flex max-w-[120px] flex-col gap-1">
<div className="h-4 w-full rounded bg-default-200" />
<div className="h-4 w-16 rounded bg-default-200" />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import React from "react";
export const SkeletonFindingSummary = () => {
return (
<div className="flex animate-pulse flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
<div className="flex items-center justify-between gap-4">
<div className="h-5 w-1/3 rounded bg-default-200" />
<div className="flex items-center gap-2">
<div className="h-5 w-16 rounded bg-default-200" />
<div className="h-5 w-16 rounded bg-default-200" />
<div className="h-5 w-5 rounded-full bg-default-200" />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import { Card, Skeleton } from "@nextui-org/react";
import React from "react";
export const SkeletonTableResources = () => {
return (
<Card className="h-full w-full space-y-5 p-4" radius="sm">
{/* Table headers */}
<div className="hidden justify-between md:flex">
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
</div>
{/* Table body */}
<div className="space-y-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
</div>
))}
</div>
</Card>
);
};

View File

@@ -0,0 +1,171 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Database } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { InfoIcon } from "@/components/icons";
import { EntityInfoShort, SnippetChip } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { DataTableColumnHeader } from "@/components/ui/table";
import { ProviderType, ResourceProps } from "@/types";
import { ResourceDetail } from "./resource-detail";
const getResourceData = (
row: { original: ResourceProps },
field: keyof ResourceProps["attributes"],
) => {
return row.original.attributes?.[field];
};
const getChipStyle = (count: number) => {
if (count === 0) return "bg-green-100 text-green-800";
if (count >= 10) return "bg-red-100 text-red-800";
if (count >= 1) return "bg-yellow-100 text-yellow-800";
};
const getProviderData = (
row: { original: ResourceProps },
field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"],
) => {
return (
row.original.relationships?.provider?.data?.attributes?.[field] ??
`No ${field} found in provider`
);
};
const ResourceDetailsCell = ({ row }: { row: any }) => {
const searchParams = useSearchParams();
const resourceId = searchParams.get("resourceId");
const isOpen = resourceId === row.original.id;
return (
<div className="flex w-9 items-center justify-center">
<TriggerSheet
triggerComponent={<InfoIcon className="text-primary" size={16} />}
title="Resource Details"
description="View the Resource details"
defaultOpen={isOpen}
>
<ResourceDetail
resourceId={row.original.id}
initialResourceData={row.original}
/>
</TriggerSheet>
</div>
);
};
export const ColumnResources: ColumnDef<ResourceProps>[] = [
{
id: "moreInfo",
header: "Details",
cell: ({ row }) => <ResourceDetailsCell row={row} />,
},
{
accessorKey: "resourceName",
header: "Resource name",
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
return (
<SnippetChip
value={resourceName as string}
formatter={(value: string) => `...${value.slice(-30)}`}
className="w-[300px] truncate"
icon={<Database size={16} />}
/>
);
},
},
{
accessorKey: "failedFindings",
header: () => <div className="text-center">Failed Findings</div>,
cell: ({ row }) => {
const failedFindingsCount = getResourceData(
row,
"failed_findings_count",
) as number;
return (
<>
<p className="text-center">
<span
className={`mx-auto flex h-6 w-6 items-center justify-center rounded-full bg-yellow-100 text-xs font-semibold text-yellow-800 ${getChipStyle(failedFindingsCount)}`}
>
{failedFindingsCount}
</span>
</p>
</>
);
},
},
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Region"} param="region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<div className="w-[80px] text-xs">
{typeof region === "string" ? region : "Invalid region"}
</div>
);
},
},
{
accessorKey: "type",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Type"} param="type" />
),
cell: ({ row }) => {
const type = getResourceData(row, "type");
return (
<div className="max-w-[150px] whitespace-nowrap break-words text-xs">
{typeof type === "string" ? type : "Invalid type"}
</div>
);
},
},
{
accessorKey: "service",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Service"}
param="service"
/>
),
cell: ({ row }) => {
const service = getResourceData(row, "service");
return (
<div className="max-w-96 truncate text-xs">
{typeof service === "string" ? service : "Invalid region"}
</div>
);
},
},
{
accessorKey: "provider",
header: "Cloud Provider",
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<EntityInfoShort
cloudProvider={provider as ProviderType}
entityAlias={alias && typeof alias === "string" ? alias : undefined}
entityId={uid && typeof uid === "string" ? uid : undefined}
/>
</>
);
},
},
];

View File

@@ -0,0 +1,3 @@
export * from "../skeleton/skeleton-table-resources";
export * from "./column-resources";
export * from "./resource-detail";

View File

@@ -0,0 +1,311 @@
"use client";
import { Snippet, Spinner } from "@nextui-org/react";
import { InfoIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { getFindingById } from "@/actions/findings";
import { getResourceById } from "@/actions/resources";
import { FindingDetail } from "@/components/findings/table/finding-detail";
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
import { CustomSection } from "@/components/ui/custom";
import {
DateWithTime,
EntityInfoShort,
InfoField,
} from "@/components/ui/entities";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { createDict } from "@/lib";
import { FindingProps, ProviderType, ResourceProps } from "@/types";
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
const buildCustomBreadcrumbs = (
_resourceName: string,
findingTitle?: string,
onBackToResource?: () => void,
): CustomBreadcrumbItem[] => {
const breadcrumbs: CustomBreadcrumbItem[] = [
{
name: "Resource Details",
isClickable: !!findingTitle,
onClick: findingTitle ? onBackToResource : undefined,
isLast: !findingTitle,
},
];
if (findingTitle) {
breadcrumbs.push({
name: findingTitle,
isLast: true,
isClickable: false,
});
}
return breadcrumbs;
};
export const ResourceDetail = ({
resourceId,
initialResourceData,
}: {
resourceId: string;
initialResourceData: ResourceProps;
}) => {
const [findingsData, setFindingsData] = useState<any[]>([]);
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
const [findingsLoading, setFindingsLoading] = useState(true);
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
null,
);
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
null,
);
useEffect(() => {
const loadFindings = async () => {
setFindingsLoading(true);
try {
const resourceData = await getResourceById(resourceId, {
include: ["findings"],
fields: ["tags", "findings"],
});
if (resourceData?.data) {
// Get tags from the detailed resource data
setResourceTags(resourceData.data.attributes.tags || {});
// Create dictionary for findings and expand them
if (resourceData.data.relationships?.findings) {
const findingsDict = createDict("findings", resourceData);
const findings =
resourceData.data.relationships.findings.data?.map(
(finding: any) => findingsDict[finding.id],
) || [];
setFindingsData(findings);
} else {
setFindingsData([]);
}
} else {
setFindingsData([]);
setResourceTags({});
}
} catch (err) {
console.error("Error loading findings:", err);
setFindingsData([]);
setResourceTags({});
} finally {
setFindingsLoading(false);
}
};
if (resourceId) {
loadFindings();
}
}, [resourceId]);
const navigateToFinding = async (findingId: string) => {
setSelectedFindingId(findingId);
try {
const findingData = await getFindingById(
findingId,
"resources,scan.provider",
);
if (findingData?.data) {
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingData);
const scanDict = createDict("scans", findingData);
const providerDict = createDict("providers", findingData);
// Expand the finding with its corresponding resource, scan, and provider
const finding = findingData.data;
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
const expandedFinding = {
...finding,
relationships: { scan, resource, provider },
};
setFindingDetails(expandedFinding);
}
} catch (error) {
console.error("Error fetching finding:", error);
}
};
const handleBackToResource = () => {
setSelectedFindingId(null);
setFindingDetails(null);
};
if (!initialResourceData) {
return (
<div className="flex min-h-96 flex-col items-center justify-center gap-4 rounded-lg p-8">
<Spinner size="lg" />
<p className="text-sm text-gray-600 dark:text-prowler-theme-pale/80">
Loading resource details...
</p>
</div>
);
}
const resource = initialResourceData;
const attributes = resource.attributes;
const providerData = resource.relationships.provider.data.attributes;
const allFindings = findingsData;
if (selectedFindingId) {
const findingTitle =
findingDetails?.attributes?.check_metadata?.checktitle ||
"Finding Detail";
return (
<div className="flex flex-col gap-4">
<BreadcrumbNavigation
mode="custom"
customItems={buildCustomBreadcrumbs(
attributes.name,
findingTitle,
handleBackToResource,
)}
/>
{findingDetails && <FindingDetail findingDetails={findingDetails} />}
</div>
);
}
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Resource Details section */}
<CustomSection title="Resource Details">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Resource UID" variant="simple">
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800" hideSymbol>
<span className="whitespace-pre-line text-xs">
{renderValue(attributes.uid)}
</span>
</Snippet>
</InfoField>
<div className="flex w-full items-end justify-between space-x-2">
<EntityInfoShort
cloudProvider={providerData.provider as ProviderType}
entityAlias={providerData.alias as string}
entityId={providerData.uid as string}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Resource Name">
{renderValue(attributes.name)}
</InfoField>
<InfoField label="Resource Type">
{renderValue(attributes.type)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Service">
{renderValue(attributes.service)}
</InfoField>
<InfoField label="Region">{renderValue(attributes.region)}</InfoField>
</div>
<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>
{resourceTags && Object.entries(resourceTags).length > 0 ? (
<div className="flex flex-col gap-4">
<h4 className="text-sm font-bold text-gray-500 dark:text-gray-400">
Tags
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{Object.entries(resourceTags).map(([key, value]) => (
<InfoField key={key} label={key}>
{renderValue(value)}
</InfoField>
))}
</div>
</div>
) : null}
</CustomSection>
{/* Finding associated with this resource section */}
<CustomSection title="Findings associated with this resource">
{findingsLoading ? (
<div className="flex items-center justify-center gap-2 py-8">
<Spinner size="sm" />
<p className="text-sm text-gray-600 dark:text-prowler-theme-pale/80">
Loading findings...
</p>
</div>
) : allFindings.length > 0 ? (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-prowler-theme-pale/80">
Total findings: {allFindings.length}
</p>
{allFindings.map((finding: any, index: number) => {
const { attributes: findingAttrs, id } = finding;
// Handle cases where finding might not have all attributes
if (!findingAttrs) {
return (
<div
key={index}
className="flex flex-col gap-2 rounded-lg px-4 py-2 shadow-small dark:bg-prowler-blue-400"
>
<p className="text-sm text-red-600">
Finding {id} - No attributes available
</p>
</div>
);
}
const { severity, check_metadata, status } = findingAttrs;
const checktitle = check_metadata?.checktitle || "Unknown check";
return (
<button
key={index}
onClick={() => navigateToFinding(id)}
className="flex w-full cursor-pointer flex-col gap-2 rounded-lg px-4 py-2 shadow-small dark:bg-prowler-blue-400"
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-left text-sm font-medium text-gray-800 dark:text-prowler-theme-pale/90">
{checktitle}
</h3>
<div className="flex items-center gap-2">
<SeverityBadge severity={severity || "-"} />
<StatusFindingBadge status={status || "-"} />
<InfoIcon
className="cursor-pointer text-primary"
size={16}
onClick={() => navigateToFinding(id)}
/>
</div>
</div>
</button>
);
})}
</div>
) : (
<p className="text-gray-600 dark:text-prowler-theme-pale/80">
No findings found for this resource.
</p>
)}
</CustomSection>
</div>
);
};

View File

@@ -0,0 +1,153 @@
"use client";
import { Icon } from "@iconify/react";
import { BreadcrumbItem, Breadcrumbs } from "@nextui-org/react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ReactNode } from "react";
export interface CustomBreadcrumbItem {
name: string;
path?: string;
isLast?: boolean;
isClickable?: boolean;
onClick?: () => void;
}
interface BreadcrumbNavigationProps {
// For automatic breadcrumbs (like navbar)
mode?: "auto" | "custom" | "hybrid";
title?: string;
icon?: string | ReactNode;
// For custom breadcrumbs (like resource-detail)
customItems?: CustomBreadcrumbItem[];
// Common options
className?: string;
paramToPreserve?: string;
showTitle?: boolean;
}
export function BreadcrumbNavigation({
mode = "auto",
title,
icon,
customItems = [],
className = "",
paramToPreserve = "scanId",
showTitle = true,
}: BreadcrumbNavigationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => {
const pathSegments = pathname
.split("/")
.filter((segment) => segment !== "");
if (pathSegments.length === 0) {
return [{ name: "Home", path: "/", isLast: true }];
}
const breadcrumbs: CustomBreadcrumbItem[] = [];
let currentPath = "";
pathSegments.forEach((segment, index) => {
currentPath += `/${segment}`;
const isLast = index === pathSegments.length - 1;
let displayName = segment.charAt(0).toUpperCase() + segment.slice(1);
// Special cases:
if (segment.includes("-")) {
displayName = segment
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
breadcrumbs.push({
name: displayName,
path: currentPath,
isLast,
isClickable: !isLast,
});
});
return breadcrumbs;
};
const buildNavigationUrl = (path: string) => {
const paramValue = searchParams.get(paramToPreserve);
if (path === "/compliance" && paramValue) {
return `/compliance?${paramToPreserve}=${paramValue}`;
}
return path;
};
const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => (
<>
{typeof icon === "string" ? (
<Icon className="text-default-500" height={24} icon={icon} width={24} />
) : icon ? (
<div className="flex h-8 w-8 items-center justify-center [&>*]:h-full [&>*]:w-full">
{icon}
</div>
) : null}
<h1
className={`text-sm font-bold text-default-700 ${isLink ? "transition-colors hover:text-primary" : ""}`}
>
{titleText}
</h1>
</>
);
// Determine which breadcrumbs to use
let breadcrumbItems: CustomBreadcrumbItem[] = [];
switch (mode) {
case "auto":
breadcrumbItems = generateAutoBreadcrumbs();
break;
case "custom":
breadcrumbItems = customItems;
break;
case "hybrid":
breadcrumbItems = [...generateAutoBreadcrumbs(), ...customItems];
break;
}
return (
<div className={className}>
<Breadcrumbs separator="/">
{breadcrumbItems.map((breadcrumb, index) => (
<BreadcrumbItem key={breadcrumb.path || index}>
{breadcrumb.isLast && showTitle && title ? (
renderTitleWithIcon(title)
) : breadcrumb.isClickable && breadcrumb.path ? (
<Link
href={buildNavigationUrl(breadcrumb.path)}
className="flex cursor-pointer items-center space-x-2"
>
<span className="text-wrap text-sm font-bold text-default-700 transition-colors hover:text-primary">
{breadcrumb.name}
</span>
</Link>
) : breadcrumb.isClickable && breadcrumb.onClick ? (
<button
onClick={breadcrumb.onClick}
className="cursor-pointer text-wrap text-sm font-medium text-primary transition-colors hover:text-primary-600"
>
{breadcrumb.name}
</button>
) : (
<span className="text-wrap text-sm font-medium text-gray-900 dark:text-gray-100">
{breadcrumb.name}
</span>
)}
</BreadcrumbItem>
))}
</Breadcrumbs>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./breadcrumb-navigation";

View File

@@ -0,0 +1,21 @@
interface CustomSectionProps {
title: string;
children: React.ReactNode;
action?: React.ReactNode;
}
export const CustomSection = ({
title,
children,
action,
}: CustomSectionProps) => (
<div className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
<div className="flex items-center justify-between">
<h3 className="text-md font-medium text-gray-800 dark:text-prowler-theme-pale/90">
{title}
</h3>
{action && <div>{action}</div>}
</div>
{children}
</div>
);

View File

@@ -6,6 +6,7 @@ export * from "./custom-dropdown-selection";
export * from "./custom-input";
export * from "./custom-loader";
export * from "./custom-radio";
export * from "./custom-section";
export * from "./custom-server-input";
export * from "./custom-table-link";
export * from "./custom-textarea";

View File

@@ -64,7 +64,7 @@ export const InfoField = ({
</span>
{variant === "simple" ? (
<div className="text-small text-gray-900 dark:text-prowler-theme-pale">
<div className="break-all text-small text-gray-900 dark:text-prowler-theme-pale">
{children}
</div>
) : variant === "transparent" ? (

View File

@@ -0,0 +1,66 @@
import { AlertIcon } from "@/components/icons/Icons";
import { cn } from "@/lib/utils";
type FeedbackType = "error" | "warning" | "info" | "success";
interface FeedbackBannerProps {
type?: FeedbackType;
title: string;
message: string;
className?: string;
}
const typeStyles: Record<
FeedbackType,
{ border: string; bg: string; text: string }
> = {
error: {
border: "border-danger",
bg: "bg-system-error-light/30 dark:bg-system-error-light/80",
text: "text-danger",
},
warning: {
border: "border-warning",
bg: "bg-yellow-100 dark:bg-yellow-200",
text: "text-yellow-800",
},
info: {
border: "border-blue-400",
bg: "bg-blue-50 dark:bg-blue-100",
text: "text-blue-800",
},
success: {
border: "border-green-500",
bg: "bg-green-50 dark:bg-green-100",
text: "text-green-800",
},
};
export const FeedbackBanner: React.FC<FeedbackBannerProps> = ({
type = "info",
title,
message,
className,
}) => {
const styles = typeStyles[type];
return (
<div
className={cn(
"rounded-xl border-l-4 p-4 shadow-sm",
styles.border,
styles.bg,
className,
)}
>
<div className="flex items-center gap-3">
<span className={cn("mt-1", styles.text)}>
<AlertIcon size={20} />
</span>
<p className={cn("text-sm", styles.text)}>
<strong>{title}</strong> {message}
</p>
</div>
</div>
);
};

View File

@@ -2,11 +2,13 @@ export * from "./accordion/Accordion";
export * from "./action-card/ActionCard";
export * from "./alert/Alert";
export * from "./alert-dialog/AlertDialog";
export * from "./breadcrumbs";
export * from "./chart/Chart";
export * from "./content-layout/content-layout";
export * from "./dialog/dialog";
export * from "./download-icon-button/download-icon-button";
export * from "./dropdown/Dropdown";
export * from "./feedback-banner/feedback-banner";
export * from "./headers/navigation-header";
export * from "./label/Label";
export * from "./main-layout/main-layout";

View File

@@ -1,12 +1,9 @@
"use client";
import { Icon } from "@iconify/react";
import { BreadcrumbItem, Breadcrumbs } from "@nextui-org/react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ReactNode } from "react";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { BreadcrumbNavigation } from "@/components/ui";
import { SheetMenu } from "../sidebar/sheet-menu";
import { UserNav } from "../user-nav/user-nav";
@@ -16,102 +13,18 @@ interface NavbarProps {
icon: string | ReactNode;
}
interface BreadcrumbItem {
name: string;
path: string;
isLast: boolean;
}
export function Navbar({ title, icon }: NavbarProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const generateBreadcrumbs = (): BreadcrumbItem[] => {
const pathSegments = pathname
.split("/")
.filter((segment) => segment !== "");
if (pathSegments.length === 0) {
return [{ name: "Home", path: "/", isLast: true }];
}
const breadcrumbs: BreadcrumbItem[] = [];
let currentPath = "";
pathSegments.forEach((segment, index) => {
currentPath += `/${segment}`;
const isLast = index === pathSegments.length - 1;
let displayName = segment.charAt(0).toUpperCase() + segment.slice(1);
//special cases:
if (segment.includes("-")) {
displayName = segment
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
breadcrumbs.push({
name: displayName,
path: currentPath,
isLast,
});
});
return breadcrumbs;
};
const buildNavigationUrl = (paramToPreserve: string, path: string) => {
const paramValue = searchParams.get(paramToPreserve);
if (path === "/compliance" && paramValue) {
return `/compliance?${paramToPreserve}=${paramValue}`;
}
return path;
};
const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => (
<>
{typeof icon === "string" ? (
<Icon className="text-default-500" height={24} icon={icon} width={24} />
) : (
<div className="flex h-8 w-8 items-center justify-center [&>*]:h-full [&>*]:w-full">
{icon}
</div>
)}
<h1
className={`text-sm font-bold text-default-700 ${isLink ? "transition-colors hover:text-primary" : ""}`}
>
{titleText}
</h1>
</>
);
const breadcrumbs = generateBreadcrumbs();
return (
<header className="sticky top-0 z-10 w-full bg-background/95 shadow backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:shadow-primary">
<div className="mx-4 flex h-14 items-center sm:mx-8">
<div className="flex items-center space-x-2">
<SheetMenu />
<Breadcrumbs separator="/">
{breadcrumbs.map((breadcrumb) => (
<BreadcrumbItem key={breadcrumb.path}>
{breadcrumb.isLast ? (
renderTitleWithIcon(title)
) : (
<Link
href={buildNavigationUrl("scanId", breadcrumb.path)}
className="flex cursor-pointer items-center space-x-2"
>
<h1 className="text-sm font-bold text-default-700 transition-colors hover:text-primary">
{breadcrumb.name}
</h1>
</Link>
)}
</BreadcrumbItem>
))}
</Breadcrumbs>
<BreadcrumbNavigation
mode="auto"
title={title}
icon={icon}
paramToPreserve="scanId"
/>
</div>
<div className="flex flex-1 items-center justify-end gap-3">
<ThemeSwitch />

View File

@@ -48,6 +48,32 @@ export const extractSortAndKey = (searchParams: Record<string, unknown>) => {
return { searchParamsKey, rawSort, encodedSort };
};
/**
* Replaces a specific field name inside a filter-style key of an object.
* @param obj - The input object with filter-style keys (e.g., { 'filter[inserted_at]': '2025-05-21' }).
* @param oldField - The field name to be replaced (e.g., 'inserted_at').
* @param newField - The field name to replace with (e.g., 'updated_at').
* @returns A new object with the updated filter key if a match is found.
*/
export function replaceFieldKey(
obj: Record<string, string>,
oldField: string,
newField: string,
): Record<string, string> {
const fieldObj: Record<string, string> = {};
for (const key in obj) {
const match = key.match(/^filter\[(.+)\]$/);
if (match && match[1] === oldField) {
const newKey = `filter[${newField}]`;
fieldObj[newKey] = obj[key];
} else {
fieldObj[key] = obj[key];
}
}
return fieldObj;
}
export const isScanEntity = (entity: ScanEntity) => {
return entity && entity.providerInfo && entity.attributes;
};

View File

@@ -9,6 +9,7 @@ import {
Group,
LayoutGrid,
Mail,
Package,
Settings,
ShieldCheck,
SquareChartGantt,
@@ -18,6 +19,7 @@ import {
User,
UserCog,
Users,
Warehouse,
} from "lucide-react";
import {
@@ -122,7 +124,24 @@ export const getMenuList = (pathname: string): GroupProps[] => {
},
],
},
{
groupLabel: "Inventory",
menus: [
{
href: "",
label: "Resources",
icon: Warehouse,
submenus: [
{
href: "/resources",
label: "Browse all resources",
icon: Package,
},
],
defaultOpen: true,
},
],
},
{
groupLabel: "Settings",
menus: [

View File

@@ -4,4 +4,5 @@ export * from "./filters";
export * from "./formSchemas";
export * from "./processors";
export * from "./providers";
export * from "./resources";
export * from "./scans";

143
ui/types/resources.ts Normal file
View File

@@ -0,0 +1,143 @@
export interface ResourceProps {
type: "resources";
id: string;
attributes: {
inserted_at: string;
updated_at: string;
uid: string;
name: string;
region: string;
service: string;
tags: Record<string, string>;
type: string;
failed_findings_count: number;
};
relationships: {
provider: {
data: {
type: "providers";
id: string;
attributes: {
inserted_at: string;
updated_at: string;
provider: string;
uid: string;
alias: string | null;
connection: {
connected: boolean;
last_checked_at: string;
};
};
relationships: {
secret: {
data: {
type: "provider-secrets";
id: string;
};
};
};
links: {
self: string;
};
};
};
findings: {
meta: {
count: number;
};
data: {
type: "findings";
id: string;
attributes: { status: string; delta: string };
}[];
};
};
links: {
self: string;
};
}
interface ResourceItemProps {
type: "providers" | "findings";
id: string;
attributes: {
uid: string;
delta: string;
status: "PASS" | "FAIL" | "MANUAL";
status_extended: string;
severity: "informational" | "low" | "medium" | "high" | "critical";
check_id: string;
check_metadata: CheckMetadataProps;
raw_result: Record<string, any>;
inserted_at: string;
updated_at: string;
first_seen_at: string;
muted: boolean;
};
relationships: {
secret: {
data: {
type: string;
id: string;
};
};
scan: {
data: {
type: string;
id: string;
};
};
provider_groups: {
meta: {
count: number;
};
data: [];
};
};
links: {
self: string;
};
}
interface CheckMetadataProps {
risk: string;
notes: string;
checkid: string;
provider: string;
severity: string;
checktype: string[];
dependson: string[];
relatedto: string[];
categories: string[];
checktitle: string;
compliance: any;
relatedurl: string;
description: string;
remediation: {
code: {
cli: string;
other: string;
nativeiac: string;
terraform: string;
};
recommendation: {
url: string;
text: string;
};
};
servicename: string;
checkaliases: string[];
resourcetype: string;
subservicename: string;
resourceidtemplate: string;
}
interface Meta {
version: string;
}
export interface ResourceApiResponse {
data: ResourceProps;
included: ResourceItemProps[];
meta: Meta;
}