From a2f9f54edd626c94dd98dea6f8724400ed299bac Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Mon, 28 Apr 2025 08:53:00 +0530 Subject: [PATCH] feat(ui): Add resources view as inventory --- ui/actions/resources/index.ts | 1 + ui/actions/resources/resources.ts | 101 ++++++++ ui/actions/scans/scans.ts | 39 +++ ui/app/(prowler)/resources/page.tsx | 229 ++++++++++++++++++ .../skeleton/skeleton-finding-summary.tsx | 14 ++ .../skeleton/skeleton-table-resources.tsx | 65 +++++ .../resources/table/column-resources.tsx | 160 ++++++++++++ .../table/data-table-row-details.tsx | 45 ++++ ui/components/resources/table/index.ts | 4 + .../resources/table/resource-detail.tsx | 183 ++++++++++++++ ui/lib/menu-list.ts | 21 +- ui/types/components.ts | 142 +++++++++++ 12 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 ui/actions/resources/index.ts create mode 100644 ui/actions/resources/resources.ts create mode 100644 ui/app/(prowler)/resources/page.tsx create mode 100644 ui/components/resources/skeleton/skeleton-finding-summary.tsx create mode 100644 ui/components/resources/skeleton/skeleton-table-resources.tsx create mode 100644 ui/components/resources/table/column-resources.tsx create mode 100644 ui/components/resources/table/data-table-row-details.tsx create mode 100644 ui/components/resources/table/index.ts create mode 100644 ui/components/resources/table/resource-detail.tsx diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts new file mode 100644 index 0000000000..3e5335fe42 --- /dev/null +++ b/ui/actions/resources/index.ts @@ -0,0 +1 @@ +export * from "./resources"; diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts new file mode 100644 index 0000000000..eb2a67e6e9 --- /dev/null +++ b/ui/actions/resources/resources.ts @@ -0,0 +1,101 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { + apiBaseUrl, + getAuthHeaders, + getErrorMessage, + parseStringify, +} from "@/lib"; + +export const getResources = async ({ + page = 1, + query = "", + sort = "", + filters = {}, + pageSize = 10, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) + redirect("resources?include=findings,provider"); + + const url = new URL(`${apiBaseUrl}/resources?include=findings,provider`); + + if (page) url.searchParams.append("page[number]", page.toString()); + + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + + 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) { + // eslint-disable-next-line no-console + console.error("Error fetching resources:", error); + return undefined; + } +}; + +export const getResourceById = async (resourceId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/resources/${resourceId}?fields[resources]=provider,region&include=findings,provider`, + ); + + try { + const resource = await fetch(url.toString(), { + headers, + }); + const data = await resource.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; + +export const getResourceFields = async (fields: string, filters = {}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/resources`); + + url.searchParams.append("fields[resources]", fields); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const resource = await fetch(url.toString(), { + headers, + }); + const data = await resource.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 0871bded69..6ba5231e1d 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -257,3 +257,42 @@ export const getExportsZip = async (scanId: string) => { }; } }; + +export const getScansByFields = async ( + fields: string = "state", + filters = {}, +) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/scans`); + + // Request only the necessary fields to optimize the response + url.searchParams.append("fields[scans]", fields); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + if (!response.ok) { + try { + const errorData = await response.json(); + throw new Error(errorData?.message || "Failed to fetch scans by state"); + } catch { + throw new Error("Failed to fetch scans by state"); + } + } + + const data = await response.json(); + + return parseStringify(data); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching scans by state:", error); + return undefined; + } +}; diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx new file mode 100644 index 0000000000..87f3ad9c2c --- /dev/null +++ b/ui/app/(prowler)/resources/page.tsx @@ -0,0 +1,229 @@ +import { Spacer } from "@nextui-org/react"; +import { format, parseISO, subDays } from "date-fns"; +import { Suspense } from "react"; + +import { getResourceFields, getResources } from "@/actions/resources"; +import { getScansByFields } from "@/actions/scans"; +import { FilterControls } from "@/components/filters"; +import { ColumnResources } from "@/components/resources/table/column-resources"; +import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources"; +import { ContentLayout } from "@/components/ui"; +import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { createDict } from "@/lib"; +import { ResourceProps, SearchParamsProps } from "@/types"; + +export default async function Resources({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) { + const searchParamsKey = JSON.stringify(searchParams || {}); + const twoDaysAgo = format(subDays(new Date(), 2), "yyyy-MM-dd"); + + // Check if the searchParams contain any date or filter + const hasDateOrScanFilter = Object.keys(searchParams).some((key) => + key.includes("inserted_at"), + ); + + // Default filters for getFindings + const defaultFilters: Record = hasDateOrScanFilter + ? {} // Do not apply default filters if there are date or filters + : { "filter[inserted_at]": twoDaysAgo, "page[size]": "100" }; // TODO: Remove page[size] 100 when metadata endpoint implemented + + const filters: Record = { + ...defaultFilters, + ...Object.fromEntries( + Object.entries(searchParams) + .filter(([key]) => key.startsWith("filter[")) + .map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(",") : value?.toString() || "", + ]), + ), + }; + + // Fetch scans data latest date not fully done + const scansData = await getScansByFields("inserted_at", { + "filter[state]": "completed", + }); + + if (scansData.data?.length !== 0) { + const latestScandate = scansData.data[0].attributes.inserted_at; + const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); + if (!hasDateOrScanFilter) { + filters["filter[inserted_at]"] = formattedDate; + } + } + + const resourcesData = await getResourceFields( + "name,type,region,service", + filters, + ); + + let resourceNameList: string[] = []; + let typeList: string[] = []; + let regionList: string[] = []; + let serviceList: string[] = []; + + if (resourcesData?.data) { + resourceNameList = Array.from( + new Set( + resourcesData.data.map((item: ResourceProps) => item.attributes.name) || + [], + ), + ); + + typeList = Array.from( + new Set( + resourcesData.data.map((item: ResourceProps) => item.attributes.type) || + [], + ), + ); + + regionList = Array.from( + new Set( + resourcesData.data.map( + (item: ResourceProps) => item.attributes.region, + ) || [], + ), + ); + + serviceList = Array.from( + new Set( + resourcesData.data.map( + (item: ResourceProps) => item.attributes.service, + ) || [], + ), + ); + } + + return ( + + + + + + }> + + + + ); +} + +const SSRDataTable = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + + const defaultSort = "name"; + const sort = searchParams.sort?.toString() || defaultSort; + + const twoDaysAgo = format(subDays(new Date(), 2), "yyyy-MM-dd"); + + // Check if the searchParams contain any date or filter + const hasDateOrScanFilter = Object.keys(searchParams).some((key) => + key.includes("inserted_at"), + ); + + // Default filters for getFindings + const defaultFilters: Record = hasDateOrScanFilter + ? {} // Do not apply default filters if there are date or filters + : { "filter[inserted_at]": twoDaysAgo }; + + const filters: Record = { + ...defaultFilters, + ...Object.fromEntries( + Object.entries(searchParams) + .filter(([key]) => key.startsWith("filter[")) + .map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(",") : value?.toString() || "", + ]), + ), + }; + + // Fetch scans data latest date + const scansData = await getScansByFields("inserted_at", { + "filter[state]": "completed", + }); + + if (scansData.data?.length !== 0) { + const latestScandate = scansData.data[0].attributes.inserted_at; + const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); + if (!hasDateOrScanFilter) { + filters["filter[inserted_at]"] = formattedDate; + } + } + + const query = filters["filter[search]"] || ""; + const resourcesData = await getResources({ + query, + page, + filters, + sort, + pageSize: 10, + }); + + const findingsDict = createDict("findings", resourcesData); + const providerDict = createDict("providers", resourcesData); + + // Expand each resources with its corresponding findings and provider + const expandedResources = resourcesData?.data + ? resourcesData.data.map((resource: ResourceProps) => { + const findings = { + meta: resource.relationships.findings.meta, + data: resource.relationships.findings.data?.map( + (finding) => findingsDict[finding.id], + ), + }; + + const provider = { + data: providerDict[resource.relationships.provider.data.id], + }; + + return { + ...resource, + relationships: { findings, provider }, + }; + }) + : []; + + const expandedResponse = { + ...resourcesData, + data: expandedResources, + }; + + return ( + + ); +}; diff --git a/ui/components/resources/skeleton/skeleton-finding-summary.tsx b/ui/components/resources/skeleton/skeleton-finding-summary.tsx new file mode 100644 index 0000000000..8d4c02d675 --- /dev/null +++ b/ui/components/resources/skeleton/skeleton-finding-summary.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +export const SkeletonFindingSummary = () => { + return (
+
+
+
+
+
+
+
+
+
); +} \ No newline at end of file diff --git a/ui/components/resources/skeleton/skeleton-table-resources.tsx b/ui/components/resources/skeleton/skeleton-table-resources.tsx new file mode 100644 index 0000000000..92db74fce9 --- /dev/null +++ b/ui/components/resources/skeleton/skeleton-table-resources.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableResources = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx new file mode 100644 index 0000000000..13ecc9d991 --- /dev/null +++ b/ui/components/resources/table/column-resources.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { useSearchParams } from "next/navigation"; + +import { InfoIcon } from "@/components/icons"; +import { EntityInfoShort } from "@/components/ui/entities"; +import { TriggerSheet } from "@/components/ui/sheet"; +import { DataTableColumnHeader } from "@/components/ui/table"; +import { ResourceProps } from "@/types"; + +import { DataTableRowDetails } from "./data-table-row-details"; + +const getResourceData = ( + row: { original: ResourceProps }, + field: keyof ResourceProps["attributes"], +) => { + return row.original.attributes?.[field] || `No ${field} found in resource`; +}; + +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 ( +
+ } + title="Resource Details" + description="View the Resource details" + defaultOpen={isOpen} + > + + +
+ ); +}; + +export const ColumnResources: ColumnDef[] = [ + { + id: "moreInfo", + header: "Details", + cell: ({ row }) => , + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const resourceName = getResourceData(row, "name"); + return ( + <> +
+ {typeof resourceName === "string" ? resourceName : "Invalid name"} +
+ + ); + }, + }, + { + accessorKey: "failedFindings", + header: "Failed Findings", + cell: ({ row }) => { + const count = row.original.relationships.findings.data.filter( + (data) => data.attributes.status === "FAIL", + ).length; + return ( + <> +
{count}
+ + ); + }, + }, + { + accessorKey: "region", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const region = getResourceData(row, "region"); + + return ( +
+ {typeof region === "string" ? region : "Invalid region"} +
+ ); + }, + }, + { + accessorKey: "type", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const type = getResourceData(row, "type"); + + return ( +
+ {typeof type === "string" ? type : "Invalid type"} +
+ ); + }, + }, + { + accessorKey: "service", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const service = getResourceData(row, "service"); + + return ( +
+ {typeof service === "string" ? service : "Invalid region"} +
+ ); + }, + }, + { + accessorKey: "provider", + header: "Cloud Provider", + cell: ({ row }) => { + const provider = getProviderData(row, "provider"); + const alias = getProviderData(row, "alias"); + const uid = getProviderData(row, "uid"); + return ( + <> + + + ); + }, + }, +]; diff --git a/ui/components/resources/table/data-table-row-details.tsx b/ui/components/resources/table/data-table-row-details.tsx new file mode 100644 index 0000000000..44d987045f --- /dev/null +++ b/ui/components/resources/table/data-table-row-details.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getResourceById } from "@/actions/resources"; +import { ResourceApiResponse, ResourceProps } from "@/types"; + +import { ResourceDetail } from "./resource-detail"; + +export const DataTableRowDetails = ({ + resourceId, + resourceData, +}: { + resourceId: string; + resourceData: ResourceProps; +}) => { + const [isLoading, setIsLoading] = useState(true); + const [resourceDetails, setResourceDetails] = + useState(null); + + useEffect(() => { + const fetchScanDetails = async () => { + try { + const result = await getResourceById(resourceId); + setResourceDetails(result); + setIsLoading(false); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error in fetchScanDetails:", error); + } finally { + setIsLoading(false); + } + }; + + fetchScanDetails(); + }, [resourceId]); + + return ( + + ); +}; diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts new file mode 100644 index 0000000000..c4b3f27f55 --- /dev/null +++ b/ui/components/resources/table/index.ts @@ -0,0 +1,4 @@ +export * from "./column-resources"; +export * from "./data-table-row-details"; +export * from "./resource-detail"; +export * from "../skeleton/skeleton-table-resources"; diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx new file mode 100644 index 0000000000..f3a421b13c --- /dev/null +++ b/ui/components/resources/table/resource-detail.tsx @@ -0,0 +1,183 @@ +import { Snippet } from "@nextui-org/react"; +import { format, parseISO } from "date-fns"; +import { InfoIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { + DateWithTime, + getProviderLogo, + InfoField, + ProviderType, +} from "@/components/ui/entities"; +import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; +import { ResourceApiResponse, ResourceProps } from "@/types"; +import { SkeletonFindingSummary } from "../skeleton/skeleton-finding-summary"; + +const renderValue = (value: string | null | undefined) => { + return value && value.trim() !== "" ? value : "-"; +}; + +const Section = ({ + title, + children, + action, +}: { + title: string; + children: React.ReactNode; + action?: React.ReactNode; +}) => ( +
+
+

+ {title} +

+ {action &&
{action}
} +
+ {children} +
+); + +export const ResourceDetail = ({ + resourceDetails, + resourceData, + isLoading, +}: { + resourceDetails: ResourceApiResponse | null; + resourceData: ResourceProps; + isLoading: boolean; +}) => { + const router = useRouter(); + + const failedFindings = resourceDetails?.included.filter( + (item) => + item.type === "findings" && + item.attributes?.status === "FAIL" && + item.attributes?.delta === "new", + ); + + const linkToFindingsFromResources = ( + uid: string, + inserted_at: string, + resourceId: string, + ) => { + const formattedDate = format(parseISO(inserted_at), "yyyy-MM-dd"); + router.push( + `/findings?filter[uid]=${uid}&filter[inserted_at]=${formattedDate}&id=${resourceId}`, + ); + }; + + return ( +
+ {/* Resource Details section */} +
+ + + + {renderValue(resourceData?.attributes.uid)} + + + +
+ + {renderValue(resourceData.attributes.name)} + + + {renderValue(resourceData.attributes.type)} + +
+
+ + {renderValue(resourceData.attributes.service)} + + + {renderValue(resourceData.attributes.region)} + +
+
+ + + + + + +
+
+ + {/* Provider Details section */} +
+
+ {resourceData.relationships.provider.data.attributes.alias && ( + + {resourceData.relationships.provider.data.attributes.alias} + + )} + + {resourceData.relationships.provider.data.attributes.uid} + +
+
+ + {getProviderLogo( + resourceData.relationships.provider.data.attributes + .provider as ProviderType, + )} + +
+
+ + {/* Finding Details section */} +
+

+ Findings Details +

+
+ {isLoading ? ( + + ) : failedFindings && failedFindings?.length > 0 ? ( + failedFindings.map((finding, index) => { + const { attributes, id } = finding; + const { severity, uid, inserted_at, check_metadata, status } = + attributes; + + const { checktitle } = check_metadata; + + return ( +
+
+
+

+ {checktitle} +

+
+
+ + + + linkToFindingsFromResources(uid, inserted_at, id) + } + /> +
+
+
+ ); + }) + ) : ( +

+ No data found. +

+ )} +
+ ); +}; diff --git a/ui/lib/menu-list.ts b/ui/lib/menu-list.ts index bae5330fe0..e8a51d02d1 100644 --- a/ui/lib/menu-list.ts +++ b/ui/lib/menu-list.ts @@ -8,6 +8,7 @@ import { Group, LayoutGrid, Mail, + Package, Settings, ShieldCheck, SquareChartGantt, @@ -17,6 +18,7 @@ import { User, UserCog, Users, + Warehouse, } from "lucide-react"; import { @@ -114,7 +116,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: [ diff --git a/ui/types/components.ts b/ui/types/components.ts index b9a9453690..7574d31002 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -767,3 +767,145 @@ export interface UserProps { dateAdded: string; status: "active" | "inactive"; } + +export interface ResourceProps { + type: "resources"; + id: string; + attributes: { + inserted_at: string; + updated_at: string; + uid: string; + name: string; + region: string; + service: string; + tags: Record; + type: string; + }; + 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 }; + }[]; + }; + }; + links: { + self: string; + }; +} +interface Provider { + 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: CheckMetadata; + raw_result: Record; + 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 CheckMetadata { + 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: Provider[]; + meta: Meta; +}