feat(ui): add service watchlist component with real API integration (#9316)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2025-11-25 17:03:24 +01:00
committed by GitHub
parent e020b3f74b
commit a07e599cfc
11 changed files with 157 additions and 66 deletions

View File

@@ -15,6 +15,8 @@ All notable changes to the **Prowler UI** are documented in this file.
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)
- New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234)
- Use branch name as region for IaC findings [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296)
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)
### 🔄 Changed

View File

@@ -4,6 +4,36 @@ import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { ServicesOverviewResponse } from "./types";
export const getServicesOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ServicesOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/services`);
// 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 services overview:", error);
return undefined;
}
};
export const getProvidersOverview = async ({
page = 1,
query = "",

View File

@@ -1,3 +1,26 @@
// Services Overview Types
// Corresponds to the /overviews/services endpoint
export interface ServiceOverviewAttributes {
total: number;
fail: number;
muted: number;
pass: number;
}
export interface ServiceOverview {
type: "services-overview";
id: string;
attributes: ServiceOverviewAttributes;
}
export interface ServicesOverviewResponse {
data: ServiceOverview[];
meta: {
version: string;
};
}
// ThreatScore Snapshot Types
// Corresponds to the ThreatScoreSnapshot model from the API

View File

@@ -17,10 +17,10 @@ export const ComplianceWatchlistSSR = async ({
const response = await getCompliancesOverview({ filters });
const { data } = adaptComplianceOverviewsResponse(response);
// Filter out ProwlerThreatScore and limit to 9 items
// Filter out ProwlerThreatScore and limit to 5 items
const items = data
.filter((item) => item.framework !== "ProwlerThreatScore")
.slice(0, 9)
.slice(0, 5)
.map((compliance) => ({
id: compliance.id,
framework: compliance.framework,

View File

@@ -1,11 +1,9 @@
"use client";
import { ArrowDownNarrowWide, ArrowUpNarrowWide } from "lucide-react";
import Image, { type StaticImageData } from "next/image";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import { SortToggleButton } from "./sort-toggle-button";
import { WatchlistCard } from "./watchlist-card";
export interface ComplianceData {
@@ -39,8 +37,6 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
value: `${item.score}%`,
}));
const SortIcon = isAsc ? ArrowUpNarrowWide : ArrowDownNarrowWide;
return (
<WatchlistCard
title="Compliance Watchlist"
@@ -48,14 +44,12 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
ctaLabel="Compliance Dashboard"
ctaHref="/compliance"
headerAction={
<Button
variant="ghost"
size="icon"
onClick={() => setIsAsc(!isAsc)}
aria-label={isAsc ? "Sort by highest score" : "Sort by lowest score"}
>
<SortIcon className="size-4" />
</Button>
<SortToggleButton
isAscending={isAsc}
onToggle={() => setIsAsc(!isAsc)}
ascendingLabel="Sort by highest score"
descendingLabel="Sort by lowest score"
/>
}
emptyState={{
message: "This space is looking empty.",

View File

@@ -1,5 +1,7 @@
export type { ComplianceData } from "./compliance-watchlist";
export { ComplianceWatchlist } from "./compliance-watchlist";
export { ComplianceWatchlistSSR } from "./compliance-watchlist.ssr";
export * from "./service-watchlist";
export { ServiceWatchlist } from "./service-watchlist";
export { ServiceWatchlistSSR } from "./service-watchlist.ssr";
export { SortToggleButton } from "./sort-toggle-button";
export * from "./watchlist-card";

View File

@@ -0,0 +1,19 @@
import { getServicesOverview, ServiceOverview } from "@/actions/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { ServiceWatchlist } from "./service-watchlist";
export const ServiceWatchlistSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const response = await getServicesOverview({ filters });
const items: ServiceOverview[] = response?.data ?? [];
return <ServiceWatchlist items={items} />;
};

View File

@@ -1,61 +1,43 @@
"use client";
import { getAWSIcon } from "@/components/icons/services/IconServices";
import { useState } from "react";
import { WatchlistCard, WatchlistItem } from "./watchlist-card";
import { ServiceOverview } from "@/actions/overview";
const MOCK_SERVICE_ITEMS: WatchlistItem[] = [
{
key: "amazon-s3-1",
icon: getAWSIcon("Amazon S3"),
label: "Amazon S3",
value: "5",
},
{
key: "amazon-ec2",
icon: getAWSIcon("Amazon EC2"),
label: "Amazon EC2",
value: "8",
},
{
key: "amazon-rds",
icon: getAWSIcon("Amazon RDS"),
label: "Amazon RDS",
value: "12",
},
{
key: "aws-iam",
icon: getAWSIcon("AWS IAM"),
label: "AWS IAM",
value: "15",
},
{
key: "aws-lambda",
icon: getAWSIcon("AWS Lambda"),
label: "AWS Lambda",
value: "22",
},
{
key: "amazon-vpc",
icon: getAWSIcon("Amazon VPC"),
label: "Amazon VPC",
value: "28",
},
{
key: "amazon-cloudwatch",
icon: getAWSIcon("AWS CloudWatch"),
label: "AWS CloudWatch",
value: "78",
},
];
import { SortToggleButton } from "./sort-toggle-button";
import { WatchlistCard } from "./watchlist-card";
export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => {
const [isAsc, setIsAsc] = useState(true);
const sortedItems = [...items]
.sort((a, b) =>
isAsc
? a.attributes.fail - b.attributes.fail
: b.attributes.fail - a.attributes.fail,
)
.slice(0, 5)
.map((item) => ({
key: item.id,
icon: <div className="bg-bg-data-muted size-3 rounded-sm" />,
label: item.id,
value: item.attributes.fail,
}));
export const ServiceWatchlist = () => {
return (
<WatchlistCard
title="Service Watchlist"
items={MOCK_SERVICE_ITEMS}
items={sortedItems}
ctaLabel="Services Dashboard"
ctaHref="/services"
headerAction={
<SortToggleButton
isAscending={isAsc}
onToggle={() => setIsAsc(!isAsc)}
ascendingLabel="Sort by highest failures"
descendingLabel="Sort by lowest failures"
/>
}
emptyState={{
message: "This space is looking empty.",
description: "to add services to your watchlist.",

View File

@@ -0,0 +1,32 @@
"use client";
import { ArrowDownNarrowWide, ArrowUpNarrowWide } from "lucide-react";
import { Button } from "@/components/shadcn/button/button";
interface SortToggleButtonProps {
isAscending: boolean;
onToggle: () => void;
ascendingLabel?: string;
descendingLabel?: string;
}
export const SortToggleButton = ({
isAscending,
onToggle,
ascendingLabel = "Sort descending",
descendingLabel = "Sort ascending",
}: SortToggleButtonProps) => {
const SortIcon = isAscending ? ArrowUpNarrowWide : ArrowDownNarrowWide;
return (
<Button
variant="ghost"
size="icon"
onClick={onToggle}
aria-label={isAscending ? ascendingLabel : descendingLabel}
>
<SortIcon className="size-4" />
</Button>
);
};

View File

@@ -18,7 +18,7 @@ import { StatusChartSkeleton } from "./components/status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./components/threat-score";
import {
ComplianceWatchlistSSR,
ServiceWatchlist,
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./components/watchlist";
@@ -64,7 +64,9 @@ export default async function NewOverviewPage({
</Suspense>
</div>
<div className="mt-6 flex gap-6">
<ServiceWatchlist />
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</div>
</ContentLayout>

View File

@@ -26,6 +26,7 @@ import {
} from "./_new-overview/components/threat-score";
import {
ComplianceWatchlistSSR,
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./_new-overview/components/watchlist";
@@ -62,6 +63,10 @@ export default async function Home({
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>