diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 3888b8fa70..e44b3339d5 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,12 +10,15 @@ All notable changes to the **Prowler UI** are documented in this file. - New findings table UI with new design system components, improved filtering UX, and enhanced table interactions [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699) - Add gradient background to Risk Plot for visual risk context [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Add ThreatScore pillar breakdown to Compliance Summary page and detail view [(#9773)](https://github.com/prowler-cloud/prowler/pull/9773) +- Add Provider and Group filters to Resources page [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) - Compliance Watchlist component in Overview page [(#9786)](https://github.com/prowler-cloud/prowler/pull/9786) ### 🔄 Changed - Refactor Lighthouse AI MCP tool filtering from blacklist to whitelist approach for improved security [(#9802)](https://github.com/prowler-cloud/prowler/pull/9802) - Refactor ScatterPlot as reusable generic component with TypeScript generics [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) +- Rename resource_group filter to group in Resources page and Overview cards [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) +- Update Resources filters to use __in format for multi-select support [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) - Swap Risk Plot axes: X = Fail Findings, Y = Prowler ThreatScore [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Remove duplicate scan_id filter badge from Findings page [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Remove unused hasDots prop from RadialChart component [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) @@ -83,6 +86,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Navigation progress bar for page transitions using Next.js `onRouterTransitionStart` [(#9465)](https://github.com/prowler-cloud/prowler/pull/9465) - Findings Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405) - Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412) +- Resource Inventory component to Overview page [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) - Add Alibaba Cloud provider [(#9501)](https://github.com/prowler-cloud/prowler/pull/9501) ### 🔄 Changed diff --git a/ui/actions/overview/index.ts b/ui/actions/overview/index.ts index fa3b434389..38cbec0a15 100644 --- a/ui/actions/overview/index.ts +++ b/ui/actions/overview/index.ts @@ -1,8 +1,8 @@ -// Re-export all overview actions from feature-based subfolders export * from "./attack-surface"; export * from "./findings"; export * from "./providers"; export * from "./regions"; +export * from "./resources-inventory"; export * from "./risk-plot"; export * from "./risk-radar"; export * from "./services"; diff --git a/ui/actions/overview/resources-inventory/index.ts b/ui/actions/overview/resources-inventory/index.ts new file mode 100644 index 0000000000..1da050a782 --- /dev/null +++ b/ui/actions/overview/resources-inventory/index.ts @@ -0,0 +1,12 @@ +export { getResourceGroupOverview } from "./resources-inventory"; +export { + adaptResourceGroupOverview, + RESOURCE_GROUP_IDS, + type ResourceGroupId, + type ResourceInventoryItem, +} from "./resources-inventory.adapter"; +export type { + ResourceGroupOverview, + ResourceGroupOverviewResponse, + SeverityBreakdown, +} from "./types"; diff --git a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts new file mode 100644 index 0000000000..5e6f5fd1f2 --- /dev/null +++ b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts @@ -0,0 +1,249 @@ +import { LucideIcon } from "lucide-react"; +import { + Activity, + BarChart3, + Bot, + Boxes, + Building2, + CloudCog, + Container, + Database, + FolderOpen, + GitBranch, + MessageSquare, + Network, + Server, + Shield, + SquareFunction, + UserRoundSearch, + Webhook, +} from "lucide-react"; + +import { + ResourceGroupOverview, + ResourceGroupOverviewResponse, + SeverityBreakdown, +} from "./types"; + +// Resource group IDs matching API values from ResourceGroup field specification +export const RESOURCE_GROUP_IDS = { + COMPUTE: "compute", + CONTAINER: "container", + SERVERLESS: "serverless", + DATABASE: "database", + STORAGE: "storage", + NETWORK: "network", + IAM: "IAM", + MESSAGING: "messaging", + SECURITY: "security", + MONITORING: "monitoring", + API_GATEWAY: "api_gateway", + AI_ML: "ai_ml", + GOVERNANCE: "governance", + COLLABORATION: "collaboration", + DEVOPS: "devops", + ANALYTICS: "analytics", +} as const; + +export type ResourceGroupId = + (typeof RESOURCE_GROUP_IDS)[keyof typeof RESOURCE_GROUP_IDS]; + +export interface ResourceInventoryItem { + id: string; + label: string; + icon: LucideIcon; + totalResources: number; + totalFindings: number; + failedFindings: number; + newFailedFindings: number; + severity: SeverityBreakdown; +} + +interface ResourceGroupConfig { + label: string; + icon: LucideIcon; +} + +const RESOURCE_GROUP_CONFIG: Record = { + [RESOURCE_GROUP_IDS.COMPUTE]: { + label: "Compute", + icon: Server, + }, + [RESOURCE_GROUP_IDS.CONTAINER]: { + label: "Container", + icon: Container, + }, + [RESOURCE_GROUP_IDS.SERVERLESS]: { + label: "Serverless", + icon: SquareFunction, + }, + [RESOURCE_GROUP_IDS.DATABASE]: { + label: "Database", + icon: Database, + }, + [RESOURCE_GROUP_IDS.STORAGE]: { + label: "Storage", + icon: FolderOpen, + }, + [RESOURCE_GROUP_IDS.NETWORK]: { + label: "Network", + icon: Network, + }, + [RESOURCE_GROUP_IDS.IAM]: { + label: "IAM", + icon: UserRoundSearch, + }, + [RESOURCE_GROUP_IDS.MESSAGING]: { + label: "Messaging", + icon: MessageSquare, + }, + [RESOURCE_GROUP_IDS.SECURITY]: { + label: "Security", + icon: Shield, + }, + [RESOURCE_GROUP_IDS.MONITORING]: { + label: "Monitoring", + icon: Activity, + }, + [RESOURCE_GROUP_IDS.API_GATEWAY]: { + label: "API Gateway", + icon: Webhook, + }, + [RESOURCE_GROUP_IDS.AI_ML]: { + label: "AI/ML", + icon: Bot, + }, + [RESOURCE_GROUP_IDS.GOVERNANCE]: { + label: "Governance", + icon: Building2, + }, + [RESOURCE_GROUP_IDS.COLLABORATION]: { + label: "Collaboration", + icon: Boxes, + }, + [RESOURCE_GROUP_IDS.DEVOPS]: { + label: "DevOps", + icon: GitBranch, + }, + [RESOURCE_GROUP_IDS.ANALYTICS]: { + label: "Analytics", + icon: BarChart3, + }, +}; + +// Default icon for unknown resource groups +const DEFAULT_ICON = CloudCog; + +// Order in which resource groups should be displayed +const RESOURCE_GROUP_ORDER: ResourceGroupId[] = [ + RESOURCE_GROUP_IDS.COMPUTE, + RESOURCE_GROUP_IDS.CONTAINER, + RESOURCE_GROUP_IDS.SERVERLESS, + RESOURCE_GROUP_IDS.DATABASE, + RESOURCE_GROUP_IDS.STORAGE, + RESOURCE_GROUP_IDS.NETWORK, + RESOURCE_GROUP_IDS.IAM, + RESOURCE_GROUP_IDS.MESSAGING, + RESOURCE_GROUP_IDS.SECURITY, + RESOURCE_GROUP_IDS.MONITORING, + RESOURCE_GROUP_IDS.API_GATEWAY, + RESOURCE_GROUP_IDS.AI_ML, + RESOURCE_GROUP_IDS.GOVERNANCE, + RESOURCE_GROUP_IDS.COLLABORATION, + RESOURCE_GROUP_IDS.DEVOPS, + RESOURCE_GROUP_IDS.ANALYTICS, +]; + +function mapResourceInventoryItem( + item: ResourceGroupOverview, +): ResourceInventoryItem { + const id = item.id; + const config = RESOURCE_GROUP_CONFIG[id as ResourceGroupId]; + + return { + id, + label: config?.label || formatResourceGroupLabel(id), + icon: config?.icon || DEFAULT_ICON, + totalResources: item.attributes.resources_count, + totalFindings: item.attributes.total_findings, + failedFindings: item.attributes.failed_findings, + newFailedFindings: item.attributes.new_failed_findings, + severity: item.attributes.severity, + }; +} + +/** + * Formats a resource group ID into a human-readable label. + * Handles snake_case and capitalizes appropriately. + */ +function formatResourceGroupLabel(id: string): string { + return id + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Adapts the resource group overview API response to a format suitable for the UI. + * Returns the items in a consistent order as defined by RESOURCE_GROUP_ORDER. + * + * @param response - The resource group overview API response + * @returns An array of ResourceInventoryItem objects sorted by the predefined order + */ +export function adaptResourceGroupOverview( + response: ResourceGroupOverviewResponse | undefined, +): ResourceInventoryItem[] { + if (!response?.data || response.data.length === 0) { + return []; + } + + // Create a map for quick lookup + const itemsMap = new Map(); + for (const item of response.data) { + itemsMap.set(item.id, item); + } + + // Return items in the predefined order + const sortedItems: ResourceInventoryItem[] = []; + for (const id of RESOURCE_GROUP_ORDER) { + const item = itemsMap.get(id); + if (item) { + sortedItems.push(mapResourceInventoryItem(item)); + } + } + + // Include any items that might be in the response but not in our predefined order + for (const item of response.data) { + if (!RESOURCE_GROUP_ORDER.includes(item.id as ResourceGroupId)) { + sortedItems.push(mapResourceInventoryItem(item)); + } + } + + return sortedItems; +} + +/** + * Returns all resource groups with default/empty values. + * Useful for showing all groups even when no data is available. + */ +export function getEmptyResourceInventoryItems(): ResourceInventoryItem[] { + return RESOURCE_GROUP_ORDER.map((id) => { + const config = RESOURCE_GROUP_CONFIG[id]; + return { + id, + label: config.label, + icon: config.icon, + totalResources: 0, + totalFindings: 0, + failedFindings: 0, + newFailedFindings: 0, + severity: { + informational: 0, + low: 0, + medium: 0, + high: 0, + critical: 0, + }, + }; + }); +} diff --git a/ui/actions/overview/resources-inventory/resources-inventory.ts b/ui/actions/overview/resources-inventory/resources-inventory.ts new file mode 100644 index 0000000000..7cd353df39 --- /dev/null +++ b/ui/actions/overview/resources-inventory/resources-inventory.ts @@ -0,0 +1,34 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +import { ResourceGroupOverviewResponse } from "./types"; + +export const getResourceGroupOverview = async ({ + filters = {}, +}: { + filters?: Record; +} = {}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/overviews/resource-groups`); + + // Handle multiple filters + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]" && value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching resource group overview:", error); + return undefined; + } +}; diff --git a/ui/actions/overview/resources-inventory/types/index.ts b/ui/actions/overview/resources-inventory/types/index.ts new file mode 100644 index 0000000000..4e67619ee4 --- /dev/null +++ b/ui/actions/overview/resources-inventory/types/index.ts @@ -0,0 +1 @@ +export * from "./resources-inventory.types"; diff --git a/ui/actions/overview/resources-inventory/types/resources-inventory.types.ts b/ui/actions/overview/resources-inventory/types/resources-inventory.types.ts new file mode 100644 index 0000000000..90f94455f9 --- /dev/null +++ b/ui/actions/overview/resources-inventory/types/resources-inventory.types.ts @@ -0,0 +1,33 @@ +// GET /api/v1/overviews/resource-groups endpoint + +interface OverviewResponseMeta { + version: string; +} + +export interface SeverityBreakdown { + informational: number; + low: number; + medium: number; + high: number; + critical: number; +} + +export interface ResourceGroupOverviewAttributes { + id: string; + total_findings: number; + failed_findings: number; + new_failed_findings: number; + resources_count: number; + severity: SeverityBreakdown; +} + +export interface ResourceGroupOverview { + type: "resource-group-overview"; + id: string; + attributes: ResourceGroupOverviewAttributes; +} + +export interface ResourceGroupOverviewResponse { + data: ResourceGroupOverview[]; + meta: OverviewResponseMeta; +} diff --git a/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.tsx b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.tsx new file mode 100644 index 0000000000..e820519308 --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.tsx @@ -0,0 +1,104 @@ +import { Bell, TriangleAlert } from "lucide-react"; +import Link from "next/link"; + +import { ResourceInventoryItem } from "@/actions/overview"; +import { CardVariant, ResourceStatsCard, StatItem } from "@/components/shadcn"; + +interface ResourcesInventoryCardItemProps { + item: ResourceInventoryItem; + filters?: Record; +} + +export function ResourcesInventoryCardItem({ + item, + filters = {}, +}: ResourcesInventoryCardItemProps) { + const hasFailedFindings = item.failedFindings > 0; + const hasResources = item.totalResources > 0; + + // Build URL with current filters + resource group specific filters + const buildResourcesUrl = () => { + if (!hasResources) return null; + + const params = new URLSearchParams(); + + // Add group specific filter + params.set("filter[groups__in]", item.id); + + // Add current page filters (provider, account, etc.) + // Transform provider_id__in to provider__in for resources endpoint + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && !params.has(key)) { + const transformedKey = + key === "filter[provider_id__in]" ? "filter[provider__in]" : key; + params.set(transformedKey, String(value)); + } + }); + + return `/resources?${params.toString()}`; + }; + + const resourcesUrl = buildResourcesUrl(); + + // Build stats array for the card content + const stats: StatItem[] = []; + if (hasFailedFindings && item.newFailedFindings > 0) { + stats.push({ + icon: Bell, + label: `${item.newFailedFindings} New`, + }); + } + + // Empty state when no resources + if (!hasResources) { + const cardContent = ( + + ); + + return cardContent; + } + + // Card with findings data + const cardContent = ( + + ); + + if (resourcesUrl) { + return ( + + {cardContent} + + ); + } + + return cardContent; +} diff --git a/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory.tsx b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory.tsx new file mode 100644 index 0000000000..79a0deb03a --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory.tsx @@ -0,0 +1,84 @@ +import Link from "next/link"; + +import { ResourceInventoryItem } from "@/actions/overview"; +import { Card, CardContent, CardTitle } from "@/components/shadcn"; + +import { ResourcesInventoryCardItem } from "./resources-inventory-card-item"; + +interface ResourcesInventoryProps { + items: ResourceInventoryItem[]; + filters?: Record; +} + +const MAX_VISIBLE_GROUPS = 8; + +export function ResourcesInventory({ + items, + filters, +}: ResourcesInventoryProps) { + const isEmpty = items.length === 0; + + // Sort by failedFindings (desc), then by totalResources (desc) to prioritize groups with issues + const sortedItems = [...items].sort((a, b) => { + if (b.failedFindings !== a.failedFindings) { + return b.failedFindings - a.failedFindings; + } + return b.totalResources - a.totalResources; + }); + + // Take top 8 most relevant groups + const visibleItems = sortedItems.slice(0, MAX_VISIBLE_GROUPS); + const firstRow = visibleItems.slice(0, 4); + const secondRow = visibleItems.slice(4, 8); + + return ( + +
+ Resource Inventory + + View All Resources + +
+ + {isEmpty ? ( +
+

+ No resource inventory data available. +

+
+ ) : ( + <> + {/* First row */} +
+ {firstRow.map((item) => ( + + ))} +
+ {/* Second row */} + {secondRow.length > 0 && ( +
+ {secondRow.map((item) => ( + + ))} +
+ )} + + )} +
+
+ ); +} diff --git a/ui/app/(prowler)/_overview/resources-inventory/index.ts b/ui/app/(prowler)/_overview/resources-inventory/index.ts new file mode 100644 index 0000000000..2d17f23f5c --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/index.ts @@ -0,0 +1,2 @@ +export { ResourcesInventorySSR } from "./resources-inventory.ssr"; +export { ResourcesInventorySkeleton } from "./resources-inventory-skeleton"; diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx new file mode 100644 index 0000000000..461fe1ae5a --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx @@ -0,0 +1,59 @@ +import { Card, CardContent, CardTitle } from "@/components/shadcn"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; + +function ResourceCardSkeleton() { + return ( +
+ {/* Header */} +
+
+ + +
+ +
+ {/* Content */} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ ); +} + +export function ResourcesInventorySkeleton() { + return ( + +
+ Resource Inventory + +
+ + {/* First row */} +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ {/* Second row */} +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx new file mode 100644 index 0000000000..a95f65f9d8 --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx @@ -0,0 +1,20 @@ +import { + adaptResourceGroupOverview, + getResourceGroupOverview, +} from "@/actions/overview"; + +import { pickFilterParams } from "../_lib/filter-params"; +import { SSRComponentProps } from "../_types"; +import { ResourcesInventory } from "./_components/resources-inventory"; + +export const ResourcesInventorySSR = async ({ + searchParams, +}: SSRComponentProps) => { + const filters = pickFilterParams(searchParams); + + const response = await getResourceGroupOverview({ filters }); + + const items = adaptResourceGroupOverview(response); + + return ; +}; diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 3c30c63576..fa99207b6b 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -25,7 +25,7 @@ import { createProviderDetailsMappingById, extractProviderIds, } from "@/lib/provider-helpers"; -import { FilterEntity, ScanEntity, ScanProps } from "@/types"; +import { ScanEntity, ScanProps } from "@/types"; import { FindingProps, SearchParamsProps } from "@/types/components"; export default async function Findings({ @@ -61,9 +61,7 @@ export default async function Findings({ // Extract provider IDs and details using helper functions const providerIds = providersData ? extractProviderIds(providersData) : []; const providerDetails = providersData - ? (createProviderDetailsMappingById(providerIds, providersData) as { - [id: string]: FilterEntity; - }[]) + ? createProviderDetailsMappingById(providerIds, providersData) : []; // Extract scan UUIDs with "completed" state and more than one resource diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index ff9d761d35..7f475687e3 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -13,6 +13,10 @@ import { import { CheckFindingsSSR } from "./_overview/check-findings"; import { GraphsTabsWrapper } from "./_overview/graphs-tabs/graphs-tabs-wrapper"; import { RiskPipelineViewSkeleton } from "./_overview/graphs-tabs/risk-pipeline-view"; +import { + ResourcesInventorySkeleton, + ResourcesInventorySSR, +} from "./_overview/resources-inventory"; import { RiskSeverityChartSkeleton, RiskSeverityChartSSR, @@ -58,6 +62,12 @@ export default async function Home({ +
+ }> + + +
+
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index e0d9e1ceec..1064da4721 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,6 +1,7 @@ import { Spacer } from "@heroui/spacer"; import { Suspense } from "react"; +import { getProviders } from "@/actions/providers"; import { getLatestMetadataInfo, getLatestResources, @@ -19,6 +20,10 @@ import { hasDateOrScanFilter, replaceFieldKey, } from "@/lib"; +import { + createProviderDetailsMappingById, + extractProviderIds, +} from "@/lib/provider-helpers"; import { ResourceProps, SearchParamsProps } from "@/types"; export default async function Resources({ @@ -35,39 +40,53 @@ export default async function Resources({ // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - const metadataInfoData = await ( - hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo - )({ - query, - filters: outputFilters, - sort: encodedSort, - }); + const [metadataInfoData, providersData] = await Promise.all([ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + filters: outputFilters, + sort: encodedSort, + }), + getProviders({ pageSize: 50 }), + ]); - // Extract unique regions, services, types, and names from the metadata endpoint + // Extract unique regions, services, groups from the metadata endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; - const uniqueResourceTypes = metadataInfoData?.data?.attributes?.types || []; + const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; + + // Extract provider IDs and details + const providerIds = providersData ? extractProviderIds(providersData) : []; + const providerDetails = providersData + ? createProviderDetailsMappingById(providerIds, providersData) + : []; return ( +