mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
feat(ui): add Resources Inventory feature (#9492)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
+104
@@ -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} />;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user