From a2f9f54edd626c94dd98dea6f8724400ed299bac Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Mon, 28 Apr 2025 08:53:00 +0530 Subject: [PATCH 01/15] 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; +} From 9639082e2067d88a12bf1422edfdb382b47f7aa6 Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Mon, 28 Apr 2025 10:25:04 +0530 Subject: [PATCH 02/15] srn Fix lint --- ui/app/(prowler)/resources/page.tsx | 48 ++++++++----------- .../skeleton/skeleton-finding-summary.tsx | 22 +++++---- ui/components/resources/table/index.ts | 2 +- .../resources/table/resource-detail.tsx | 1 + 4 files changed, 34 insertions(+), 39 deletions(-) diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index 87f3ad9c2c..bfc0e86e18 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,12 +1,12 @@ import { Spacer } from "@nextui-org/react"; -import { format, parseISO, subDays } from "date-fns"; +import { format, parseISO } 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 { ColumnResources } from "@/components/resources/table/column-resources"; import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; import { createDict } from "@/lib"; @@ -18,7 +18,6 @@ export default async function Resources({ 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) => @@ -28,7 +27,7 @@ export default async function Resources({ // 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 + : { "page[size]": "100" }; // TODO: Remove page[size] 100 when metadata endpoint implemented const filters: Record = { ...defaultFilters, @@ -55,6 +54,7 @@ export default async function Resources({ } } + // Resource call for filters const resourcesData = await getResourceFields( "name,type,region,service", filters, @@ -69,14 +69,14 @@ export default async function Resources({ 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) || - [], + [], ), ); @@ -144,20 +144,12 @@ const SSRDataTable = async ({ 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[")) @@ -196,22 +188,22 @@ const SSRDataTable = async ({ // 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 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], - }; + const provider = { + data: providerDict[resource.relationships.provider.data.id], + }; - return { - ...resource, - relationships: { findings, provider }, - }; - }) + return { + ...resource, + relationships: { findings, provider }, + }; + }) : []; const expandedResponse = { diff --git a/ui/components/resources/skeleton/skeleton-finding-summary.tsx b/ui/components/resources/skeleton/skeleton-finding-summary.tsx index 8d4c02d675..f3bc958c79 100644 --- a/ui/components/resources/skeleton/skeleton-finding-summary.tsx +++ b/ui/components/resources/skeleton/skeleton-finding-summary.tsx @@ -1,14 +1,16 @@ import React from "react"; export const SkeletonFindingSummary = () => { - return (
-
-
-
-
-
-
-
+ return ( +
+
+
+
+
+
+
-
); -} \ No newline at end of file +
+
+ ); +}; diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts index c4b3f27f55..c8c06fca14 100644 --- a/ui/components/resources/table/index.ts +++ b/ui/components/resources/table/index.ts @@ -1,4 +1,4 @@ +export * from "../skeleton/skeleton-table-resources"; 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 index f3a421b13c..624c780b0f 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -11,6 +11,7 @@ import { } 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) => { From 9365eaf3da4bf1686fafb25b8a4895605fa86788 Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Fri, 16 May 2025 11:58:32 +0200 Subject: [PATCH 03/15] fix: fix import for ProviderType --- ui/components/resources/table/resource-detail.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index 624c780b0f..710df9290d 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -7,10 +7,9 @@ import { DateWithTime, getProviderLogo, InfoField, - ProviderType, } from "@/components/ui/entities"; import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; -import { ResourceApiResponse, ResourceProps } from "@/types"; +import { ProviderType, ResourceApiResponse, ResourceProps } from "@/types"; import { SkeletonFindingSummary } from "../skeleton/skeleton-finding-summary"; From 72137590fedf32fd83d40331c6f07b46b227f4f9 Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Fri, 16 May 2025 13:33:18 +0200 Subject: [PATCH 04/15] refactor: remove getScansByFields and use getScans with flexible filters --- ui/actions/scans/scans.ts | 58 ++----- ui/app/(prowler)/resources/page.tsx | 17 ++- ui/app/(prowler)/scans/page.tsx | 8 +- .../resources/table/resource-detail.tsx | 6 +- ui/types/components.ts | 144 +----------------- ui/types/index.ts | 1 + ui/types/resources.ts | 142 +++++++++++++++++ 7 files changed, 175 insertions(+), 201 deletions(-) create mode 100644 ui/types/resources.ts diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 74b3c96268..7d3647f1cf 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -16,6 +16,12 @@ export const getScans = async ({ sort = "", filters = {}, pageSize = 10, +}: { + page?: number; + query?: string; + sort?: string; + filters?: Record; + pageSize?: number; }) => { const headers = await getAuthHeaders({ contentType: false }); @@ -28,23 +34,18 @@ export const getScans = async ({ if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); - // 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; } catch (error) { - // eslint-disable-next-line no-console console.error("Error fetching scans:", error); return undefined; } @@ -260,45 +261,6 @@ 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; - } -}; - export const getComplianceCsv = async ( scanId: string, complianceId: string, diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index bfc0e86e18..a1cd98416d 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -3,7 +3,7 @@ import { format, parseISO } from "date-fns"; import { Suspense } from "react"; import { getResourceFields, getResources } from "@/actions/resources"; -import { getScansByFields } from "@/actions/scans"; +import { getScans } from "@/actions/scans"; import { FilterControls } from "@/components/filters"; import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources"; import { ColumnResources } from "@/components/resources/table/column-resources"; @@ -41,9 +41,11 @@ export default async function Resources({ ), }; - // Fetch scans data latest date not fully done - const scansData = await getScansByFields("inserted_at", { - "filter[state]": "completed", + const scansData = await getScans({ + filters: { + "filter[state]": "completed", + "fields[scans]": "inserted_at", + }, }); if (scansData.data?.length !== 0) { @@ -161,8 +163,11 @@ const SSRDataTable = async ({ }; // Fetch scans data latest date - const scansData = await getScansByFields("inserted_at", { - "filter[state]": "completed", + const scansData = await getScans({ + filters: { + "filter[state]": "completed", + "fields[scans]": "inserted_at", + }, }); if (scansData.data?.length !== 0) { diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 737d7f284b..30c1af6f2f 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -123,7 +123,13 @@ const SSRDataTableScans = async ({ const query = (filters["filter[search]"] as string) || ""; // Fetch scans data - const scansData = await getScans({ query, page, sort, filters, pageSize }); + const scansData = await getScans({ + query, + page, + sort, + filters: filters as Record, + pageSize, + }); // Handle expanded scans data const expandedScansData = await Promise.all( diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index 710df9290d..6093d99748 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -131,10 +131,10 @@ export const ResourceDetail = ({
- {/* Finding Details section */} + {/* Finding associated with this resource section */}
-

- Findings Details +

+ Findings associated with this resource

{isLoading ? ( diff --git a/ui/types/components.ts b/ui/types/components.ts index 69c83c6d73..874c57e99c 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -1,7 +1,7 @@ import { LucideIcon } from "lucide-react"; import { SVGProps } from "react"; -import { ProviderType } from "./providers"; +import { ProviderProps, ProviderType } from "./providers"; export type IconSvgProps = SVGProps & { size?: number; @@ -704,145 +704,3 @@ 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; -} diff --git a/ui/types/index.ts b/ui/types/index.ts index e35a2815da..40d4d9a1ef 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -3,3 +3,4 @@ export * from "./components"; export * from "./filters"; export * from "./formSchemas"; export * from "./providers"; +export * from "./resources"; diff --git a/ui/types/resources.ts b/ui/types/resources.ts new file mode 100644 index 0000000000..6d23b5bc51 --- /dev/null +++ b/ui/types/resources.ts @@ -0,0 +1,142 @@ +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 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; + 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; +} From 8afd3f9b2b8031943cb9f75f5e2f7285687c6818 Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Wed, 21 May 2025 20:46:56 +0530 Subject: [PATCH 05/15] srn Fixed PR comments --- ui/actions/scans/scans.ts | 1 + ui/app/(prowler)/resources/page.tsx | 140 ++++++++---------- .../resources/table/column-resources.tsx | 32 +++- .../resources/table/resource-detail.tsx | 62 ++++---- ui/components/ui/entities/info-field.tsx | 2 +- ui/lib/helper.ts | 20 +++ ui/types/components.ts | 2 +- 7 files changed, 137 insertions(+), 122 deletions(-) diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 7d3647f1cf..e6813bc234 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -46,6 +46,7 @@ export const getScans = async ({ revalidatePath("/scans"); return parsedData; } catch (error) { + // eslint-disable-next-line no-console console.error("Error fetching scans:", error); return undefined; } diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index a1cd98416d..6a2bbbadab 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -9,7 +9,7 @@ import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton import { ColumnResources } from "@/components/resources/table/column-resources"; import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; -import { createDict } from "@/lib"; +import { createDict, extractFiltersAndQuery, extractSortAndKey, hasDateOrScanFilter, replaceFilterFieldKey } from "@/lib"; import { ResourceProps, SearchParamsProps } from "@/types"; export default async function Resources({ @@ -20,46 +20,31 @@ export default async function Resources({ const searchParamsKey = JSON.stringify(searchParams || {}); // Check if the searchParams contain any date or filter - const hasDateOrScanFilter = Object.keys(searchParams).some((key) => - key.includes("inserted_at"), - ); + const hasDateOrScan = hasDateOrScanFilter(searchParams); - // Default filters for getFindings - const defaultFilters: Record = hasDateOrScanFilter - ? {} // Do not apply default filters if there are date or filters - : { "page[size]": "100" }; // TODO: Remove page[size] 100 when metadata endpoint implemented + const { filters } = extractFiltersAndQuery(searchParams); + filters["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() || "", - ]), - ), - }; + if (!hasDateOrScan) { + const scansData = await getScans({ + filters: { + "fields[scans]": "inserted_at", + }, + }); - const scansData = await getScans({ - filters: { - "filter[state]": "completed", - "fields[scans]": "inserted_at", - }, - }); - - 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; + if (scansData.data?.length !== 0) { + const latestScandate = scansData.data?.[0]?.attributes?.inserted_at; + const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); + filters["filter[updated_at]"] = formattedDate; } } + const outputFilters = replaceFilterFieldKey(filters, 'inserted_at', 'updated_at'); + // Resource call for filters const resourcesData = await getResourceFields( "name,type,region,service", - filters, + outputFilters, ); let resourceNameList: string[] = []; @@ -71,14 +56,14 @@ export default async function Resources({ 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) || - [], + [], ), ); @@ -142,49 +127,40 @@ const SSRDataTable = async ({ searchParams: SearchParamsProps; }) => { const page = parseInt(searchParams.page?.toString() || "1", 10); - + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); const defaultSort = "name"; - const sort = searchParams.sort?.toString() || defaultSort; - - // Check if the searchParams contain any date or filter - const hasDateOrScanFilter = Object.keys(searchParams).some((key) => - key.includes("inserted_at"), - ); - - const filters: Record = { - ...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 getScans({ - filters: { - "filter[state]": "completed", - "fields[scans]": "inserted_at", - }, + const { encodedSort } = extractSortAndKey({ + ...searchParams, + sort: searchParams.sort ?? defaultSort, }); - 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; + // Check if the searchParams contain any date or filter + const hasDateOrScan = hasDateOrScanFilter(searchParams); + + const { filters, query } = extractFiltersAndQuery(searchParams); + + if (!hasDateOrScan) { + // Fetch scans data latest date + const scansData = await getScans({ + filters: { + "fields[scans]": "inserted_at", + }, + }); + + if (scansData.data?.length !== 0) { + const latestScandate = scansData?.data?.[0]?.attributes?.inserted_at; + const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); + filters["filter[updated_at]"] = formattedDate; } } - const query = filters["filter[search]"] || ""; + const outputFilters = replaceFilterFieldKey(filters, 'inserted_at', 'updated_at'); const resourcesData = await getResources({ query, page, - filters, - sort, - pageSize: 10, + filters: outputFilters, + sort: encodedSort, + pageSize, }); const findingsDict = createDict("findings", resourcesData); @@ -193,22 +169,22 @@ const SSRDataTable = async ({ // 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 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], - }; + const provider = { + data: providerDict[resource.relationships.provider.data.id], + }; - return { - ...resource, - relationships: { findings, provider }, - }; - }) + return { + ...resource, + relationships: { findings, provider }, + }; + }) : []; const expandedResponse = { diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index 13ecc9d991..50c79f31ee 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -18,6 +18,12 @@ const getResourceData = ( return row.original.attributes?.[field] || `No ${field} found in resource`; }; +const getChipStyle = (count: number) => { + if (count > 10) return "bg-red-100 text-red-800"; + if (count > 1) return "bg-yellow-100 text-yellow-800"; + return "bg-green-100 text-green-800"; +}; + const getProviderData = ( row: { original: ResourceProps }, field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"], @@ -69,8 +75,14 @@ export const ColumnResources: ColumnDef[] = [ const resourceName = getResourceData(row, "name"); return ( <> -
- {typeof resourceName === "string" ? resourceName : "Invalid name"} +
+
+

+ {typeof resourceName === "string" + ? resourceName + : "Invalid name"} +

+
); @@ -78,14 +90,20 @@ export const ColumnResources: ColumnDef[] = [ }, { accessorKey: "failedFindings", - header: "Failed Findings", + header: () =>
Failed Findings
, cell: ({ row }) => { const count = row.original.relationships.findings.data.filter( (data) => data.attributes.status === "FAIL", ).length; return ( <> -
{count}
+

+ + {count} + +

); }, @@ -99,7 +117,7 @@ export const ColumnResources: ColumnDef[] = [ const region = getResourceData(row, "region"); return ( -
+
{typeof region === "string" ? region : "Invalid region"}
); @@ -114,7 +132,7 @@ export const ColumnResources: ColumnDef[] = [ const type = getResourceData(row, "type"); return ( -
+
{typeof type === "string" ? type : "Invalid type"}
); @@ -133,7 +151,7 @@ export const ColumnResources: ColumnDef[] = [ const service = getResourceData(row, "service"); return ( -
+
{typeof service === "string" ? service : "Invalid region"}
); diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index 6093d99748..c6bc20cd46 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -5,11 +5,11 @@ import { useRouter } from "next/navigation"; import { DateWithTime, - getProviderLogo, + EntityInfoShort, InfoField, } from "@/components/ui/entities"; import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; -import { ProviderType, ResourceApiResponse, ResourceProps } from "@/types"; +import { ResourceApiResponse, ResourceProps } from "@/types"; import { SkeletonFindingSummary } from "../skeleton/skeleton-finding-summary"; @@ -70,13 +70,35 @@ export const ResourceDetail = ({
{/* Resource Details section */}
- - - - {renderValue(resourceData?.attributes.uid)} - - - +
+ + + + {renderValue(resourceData?.attributes.uid)} + + + + + + +
+
{renderValue(resourceData.attributes.name)} @@ -109,28 +131,6 @@ export const ResourceDetail = ({
- {/* 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 associated with this resource section */}

diff --git a/ui/components/ui/entities/info-field.tsx b/ui/components/ui/entities/info-field.tsx index 0607efdf7b..0c2908b6e0 100644 --- a/ui/components/ui/entities/info-field.tsx +++ b/ui/components/ui/entities/info-field.tsx @@ -42,7 +42,7 @@ export const InfoField = ({ {variant === "simple" ? ( -
+
{children}
) : ( diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index db664e2a18..c29b037df5 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -269,3 +269,23 @@ export const permissionFormFields: PermissionInfo[] = [ description: "Provides access to billing settings and invoices", }, ]; + +export function replaceFilterFieldKey( + obj: Record, + oldField: string, + newField: string +): Record { + const fieldObj: Record = {}; + + 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; +} diff --git a/ui/types/components.ts b/ui/types/components.ts index 874c57e99c..e45913efc7 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -1,7 +1,7 @@ import { LucideIcon } from "lucide-react"; import { SVGProps } from "react"; -import { ProviderProps, ProviderType } from "./providers"; +import { ProviderType } from "./providers"; export type IconSvgProps = SVGProps & { size?: number; From b9c5fa71a788bfbd4de0c5458c654ad919331a26 Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Wed, 21 May 2025 20:51:53 +0530 Subject: [PATCH 06/15] srn lint fix --- ui/app/(prowler)/resources/page.tsx | 52 ++++++++++++++++++----------- ui/lib/helper.ts | 2 +- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index 6a2bbbadab..0175fcb5d9 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -9,7 +9,13 @@ import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton 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, replaceFilterFieldKey } from "@/lib"; +import { + createDict, + extractFiltersAndQuery, + extractSortAndKey, + hasDateOrScanFilter, + replaceFilterFieldKey, +} from "@/lib"; import { ResourceProps, SearchParamsProps } from "@/types"; export default async function Resources({ @@ -39,7 +45,11 @@ export default async function Resources({ } } - const outputFilters = replaceFilterFieldKey(filters, 'inserted_at', 'updated_at'); + const outputFilters = replaceFilterFieldKey( + filters, + "inserted_at", + "updated_at", + ); // Resource call for filters const resourcesData = await getResourceFields( @@ -56,14 +66,14 @@ export default async function Resources({ 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) || - [], + [], ), ); @@ -154,7 +164,11 @@ const SSRDataTable = async ({ } } - const outputFilters = replaceFilterFieldKey(filters, 'inserted_at', 'updated_at'); + const outputFilters = replaceFilterFieldKey( + filters, + "inserted_at", + "updated_at", + ); const resourcesData = await getResources({ query, page, @@ -169,22 +183,22 @@ const SSRDataTable = async ({ // 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 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], - }; + const provider = { + data: providerDict[resource.relationships.provider.data.id], + }; - return { - ...resource, - relationships: { findings, provider }, - }; - }) + return { + ...resource, + relationships: { findings, provider }, + }; + }) : []; const expandedResponse = { diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index c29b037df5..229ec34cbb 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -273,7 +273,7 @@ export const permissionFormFields: PermissionInfo[] = [ export function replaceFilterFieldKey( obj: Record, oldField: string, - newField: string + newField: string, ): Record { const fieldObj: Record = {}; From 0206674fc999d2032c7ef23bbb469c6de2dbcc22 Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Wed, 21 May 2025 21:39:48 +0530 Subject: [PATCH 07/15] srn Updated failed findings count --- ui/app/(prowler)/resources/page.tsx | 4 ++-- ui/components/resources/table/column-resources.tsx | 4 +++- ui/types/resources.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index 0175fcb5d9..f1e65d2f23 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -38,7 +38,7 @@ export default async function Resources({ }, }); - if (scansData.data?.length !== 0) { + if (scansData?.data?.length !== 0) { const latestScandate = scansData.data?.[0]?.attributes?.inserted_at; const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); filters["filter[updated_at]"] = formattedDate; @@ -157,7 +157,7 @@ const SSRDataTable = async ({ }, }); - if (scansData.data?.length !== 0) { + if (scansData?.data?.length !== 0) { const latestScandate = scansData?.data?.[0]?.attributes?.inserted_at; const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); filters["filter[updated_at]"] = formattedDate; diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index 50c79f31ee..a396408408 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -93,8 +93,10 @@ export const ColumnResources: ColumnDef[] = [ header: () =>
Failed Findings
, cell: ({ row }) => { const count = row.original.relationships.findings.data.filter( - (data) => data.attributes.status === "FAIL", + (data) => + data.attributes.status === "FAIL" && data.attributes.delta === "new", ).length; + return ( <>

diff --git a/ui/types/resources.ts b/ui/types/resources.ts index 6d23b5bc51..5db29804a1 100644 --- a/ui/types/resources.ts +++ b/ui/types/resources.ts @@ -47,7 +47,7 @@ export interface ResourceProps { data: { type: "findings"; id: string; - attributes: { status: string }; + attributes: { status: string; delta: string }; }[]; }; }; From c1380e397cf91d0e0701b5c11d54ec37d88a017f Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Wed, 21 May 2025 21:56:32 +0530 Subject: [PATCH 08/15] srn Helper method correction --- ui/lib/helper-filters.ts | 27 +++++++++++++++++++++++++++ ui/lib/helper.ts | 20 -------------------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/ui/lib/helper-filters.ts b/ui/lib/helper-filters.ts index fe17185746..3eeaa84ea3 100644 --- a/ui/lib/helper-filters.ts +++ b/ui/lib/helper-filters.ts @@ -44,3 +44,30 @@ export const extractSortAndKey = (searchParams: Record) => { 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 replaceFilterFieldKey( + obj: Record, + oldField: string, + newField: string, +): Record { + const fieldObj: Record = {}; + + 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; +} diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 229ec34cbb..db664e2a18 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -269,23 +269,3 @@ export const permissionFormFields: PermissionInfo[] = [ description: "Provides access to billing settings and invoices", }, ]; - -export function replaceFilterFieldKey( - obj: Record, - oldField: string, - newField: string, -): Record { - const fieldObj: Record = {}; - - 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; -} From 6bbf65189578626525a6dc0baa7f5c70d4b7a52b Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Thu, 22 May 2025 09:24:23 +0200 Subject: [PATCH 09/15] chore: tweak styles --- .../resources/table/resource-detail.tsx | 55 ++++++++----------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index c6bc20cd46..3e05413e20 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -78,25 +78,22 @@ export const ResourceDetail = ({ - - - +

@@ -132,11 +129,9 @@ export const ResourceDetail = ({ {/* Finding associated with this resource section */} -
-

- Findings associated with this resource -

-
+

+ Findings associated with this resource +

{isLoading ? ( ) : failedFindings && failedFindings?.length > 0 ? ( @@ -150,14 +145,12 @@ export const ResourceDetail = ({ return (
-
-
-

- {checktitle} -

-
+
+

+ {checktitle} +

From a75788921c53680898945a0017e7ec355b664a89 Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Thu, 22 May 2025 09:45:56 +0200 Subject: [PATCH 10/15] chore: tweak styles --- ui/components/resources/table/column-resources.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index a396408408..bcef58caeb 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -19,9 +19,9 @@ const getResourceData = ( }; const getChipStyle = (count: number) => { - if (count > 10) return "bg-red-100 text-red-800"; - if (count > 1) return "bg-yellow-100 text-yellow-800"; - return "bg-green-100 text-green-800"; + 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 = ( @@ -75,7 +75,7 @@ export const ColumnResources: ColumnDef[] = [ const resourceName = getResourceData(row, "name"); return ( <> -
+

{typeof resourceName === "string" @@ -119,7 +119,7 @@ export const ColumnResources: ColumnDef[] = [ const region = getResourceData(row, "region"); return ( -

+
{typeof region === "string" ? region : "Invalid region"}
); @@ -134,7 +134,7 @@ export const ColumnResources: ColumnDef[] = [ const type = getResourceData(row, "type"); return ( -
+
{typeof type === "string" ? type : "Invalid type"}
); @@ -153,7 +153,7 @@ export const ColumnResources: ColumnDef[] = [ const service = getResourceData(row, "service"); return ( -
+
{typeof service === "string" ? service : "Invalid region"}
); From 50f5efb462d9a28654ceb15dffc0f2be86882e5e Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Thu, 22 May 2025 10:41:01 +0200 Subject: [PATCH 11/15] feat: add feedback-banner component --- .../ui/feedback-banner/feedback-banner.tsx | 66 +++++++++++++++++++ ui/components/ui/index.ts | 1 + 2 files changed, 67 insertions(+) create mode 100644 ui/components/ui/feedback-banner/feedback-banner.tsx diff --git a/ui/components/ui/feedback-banner/feedback-banner.tsx b/ui/components/ui/feedback-banner/feedback-banner.tsx new file mode 100644 index 0000000000..806761e5ed --- /dev/null +++ b/ui/components/ui/feedback-banner/feedback-banner.tsx @@ -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 = ({ + type = "info", + title, + message, + className, +}) => { + const styles = typeStyles[type]; + + return ( +
+
+ + + +

+ {title} {message} +

+
+
+ ); +}; diff --git a/ui/components/ui/index.ts b/ui/components/ui/index.ts index 6fb4555d80..fb81c25dc1 100644 --- a/ui/components/ui/index.ts +++ b/ui/components/ui/index.ts @@ -7,6 +7,7 @@ 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"; From cf6e6ac03de0056f2b6a64c3f94d4cba8261990d Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Tue, 3 Jun 2025 11:51:00 +0530 Subject: [PATCH 12/15] srn updated check --- ui/app/(prowler)/resources/page.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index f1e65d2f23..bda5ba9a13 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -40,8 +40,10 @@ export default async function Resources({ if (scansData?.data?.length !== 0) { const latestScandate = scansData.data?.[0]?.attributes?.inserted_at; - const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); - filters["filter[updated_at]"] = formattedDate; + if (latestScandate) { + const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); + filters["filter[updated_at]"] = formattedDate; + } } } From eebb64a25bdbcb34d59bb91b04149ee65ea745a6 Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Tue, 3 Jun 2025 11:57:23 +0530 Subject: [PATCH 13/15] srn Updated chips to be round --- ui/components/resources/table/column-resources.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index bcef58caeb..585a184c43 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -101,7 +101,7 @@ export const ColumnResources: ColumnDef[] = [ <>

{count} From caef85e771299be9fc20f2ad3de8de29b62917ca Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Tue, 3 Jun 2025 12:03:52 +0530 Subject: [PATCH 14/15] srn Provider details UI update --- .../resources/table/resource-detail.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index 3e05413e20..18be5b1976 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -78,22 +78,24 @@ export const ResourceDetail = ({ - + + } + entityAlias={ + resourceData.relationships.provider.data.attributes + .alias as string + } + entityId={ + resourceData.relationships.provider.data.attributes.uid as string + } + /> +

From fe19fdf1fa8cf6ca31f1460ff145e53524e0a46a Mon Sep 17 00:00:00 2001 From: sumit_chaturvedi Date: Tue, 3 Jun 2025 12:06:22 +0530 Subject: [PATCH 15/15] srn Lint fixes --- ui/components/resources/table/column-resources.tsx | 2 +- ui/components/resources/table/resource-detail.tsx | 11 ++++++----- ui/types/index.ts | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index 585a184c43..9c8a5c6927 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -101,7 +101,7 @@ export const ColumnResources: ColumnDef[] = [ <>

{count} diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index 18be5b1976..ee57e1ffec 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -82,17 +82,18 @@ export const ResourceDetail = ({

diff --git a/ui/types/index.ts b/ui/types/index.ts index 4307efd3ed..4148f21f46 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -3,5 +3,5 @@ export * from "./components"; export * from "./filters"; export * from "./formSchemas"; export * from "./providers"; -export * from "./scans"; export * from "./resources"; +export * from "./scans";