feat(ui): add Resources Inventory feature (#9492)

Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
Alejandro Bailo
2026-01-15 16:25:12 +01:00
committed by GitHub
parent 2c4f866e42
commit d5e2c930a9
15 changed files with 650 additions and 21 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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";
@@ -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";
@@ -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<ResourceGroupId, ResourceGroupConfig> = {
[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<string, ResourceGroupOverview>();
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,
},
};
});
}
@@ -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<string, string | string[] | undefined>;
} = {}): Promise<ResourceGroupOverviewResponse | undefined> => {
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;
}
};
@@ -0,0 +1 @@
export * from "./resources-inventory.types";
@@ -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;
}
@@ -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<string, string | string[] | undefined>;
}
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 = (
<ResourceStatsCard
header={{
icon: item.icon,
title: item.label,
resourceCount: item.totalResources,
}}
emptyState={{
message: "No Findings to display",
}}
className="flex-1"
/>
);
return cardContent;
}
// Card with findings data
const cardContent = (
<ResourceStatsCard
header={{
icon: item.icon,
title: item.label,
resourceCount: item.totalResources,
}}
badge={{
icon: TriangleAlert,
count: item.failedFindings,
variant: CardVariant.fail,
}}
label="Fail Findings"
stats={stats}
variant={hasFailedFindings ? CardVariant.fail : CardVariant.default}
className={
hasFailedFindings
? "hover:border-bg-fail/60 flex-1 cursor-pointer transition-all"
: "flex-1"
}
/>
);
if (resourcesUrl) {
return (
<Link href={resourcesUrl} className="flex flex-1">
{cardContent}
</Link>
);
}
return cardContent;
}
@@ -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<string, string | string[] | undefined>;
}
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 (
<Card variant="base" className="flex w-full flex-col">
<div className="flex w-full items-center justify-between">
<CardTitle>Resource Inventory</CardTitle>
<Link
href="/resources"
className="text-button-tertiary hover:text-button-tertiary-hover text-sm font-medium transition-colors"
>
View All Resources
</Link>
</div>
<CardContent className="mt-4 flex flex-col gap-3">
{isEmpty ? (
<div
className="flex w-full items-center justify-center py-8"
role="status"
>
<p className="text-text-neutral-tertiary text-sm">
No resource inventory data available.
</p>
</div>
) : (
<>
{/* First row */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{firstRow.map((item) => (
<ResourcesInventoryCardItem
key={item.id}
item={item}
filters={filters}
/>
))}
</div>
{/* Second row */}
{secondRow.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{secondRow.map((item) => (
<ResourcesInventoryCardItem
key={item.id}
item={item}
filters={filters}
/>
))}
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,2 @@
export { ResourcesInventorySSR } from "./resources-inventory.ssr";
export { ResourcesInventorySkeleton } from "./resources-inventory-skeleton";
@@ -0,0 +1,59 @@
import { Card, CardContent, CardTitle } from "@/components/shadcn";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
function ResourceCardSkeleton() {
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex flex-1 flex-col gap-2 rounded-xl border px-3 py-2">
{/* Header */}
<div className="flex w-full items-center gap-1">
<div className="flex flex-1 items-center gap-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-3 w-16" />
</div>
{/* Content */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
<div className="ml-6 flex flex-col gap-0.5">
<div className="flex items-center gap-1">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-4 w-12" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</div>
</div>
);
}
export function ResourcesInventorySkeleton() {
return (
<Card variant="base" className="flex w-full flex-col">
<div className="flex w-full items-center justify-between">
<CardTitle>Resource Inventory</CardTitle>
<Skeleton className="h-5 w-28" />
</div>
<CardContent className="mt-4 flex flex-col gap-3">
{/* First row */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<ResourceCardSkeleton key={`row1-${i}`} />
))}
</div>
{/* Second row */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<ResourceCardSkeleton key={`row2-${i}`} />
))}
</div>
</CardContent>
</Card>
);
}
@@ -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 <ResourcesInventory items={items} filters={filters} />;
};
+2 -4
View File
@@ -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
+10
View File
@@ -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({
</Suspense>
</div>
<div className="mt-6">
<Suspense fallback={<ResourcesInventorySkeleton />}>
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:flex-wrap sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
+35 -16
View File
@@ -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 (
<ContentLayout title="Resources" icon="lucide:warehouse">
<FilterControls search date />
<Spacer y={4} />
<DataTableFilterCustom
filters={[
{
key: "region",
key: "provider__in",
labelCheckboxGroup: "Provider",
values: providerIds,
valueLabelMapping: providerDetails,
},
{
key: "region__in",
labelCheckboxGroup: "Region",
values: uniqueRegions,
},
{
key: "type",
labelCheckboxGroup: "Type",
values: uniqueResourceTypes,
},
{
key: "service",
key: "service__in",
labelCheckboxGroup: "Service",
values: uniqueServices,
},
{
key: "groups__in",
labelCheckboxGroup: "Group",
values: uniqueGroups,
},
]}
/>
<Spacer y={8} />