feat: add watchlist component (#9199)

Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
Alejandro Bailo
2025-11-25 16:01:38 +01:00
committed by GitHub
parent 8e7e376e4f
commit e020b3f74b
17 changed files with 553 additions and 20 deletions

View File

@@ -0,0 +1,137 @@
import { StaticImageData } from "next/image";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { MetaDataProps } from "@/types";
import { ComplianceOverviewData } from "@/types/compliance";
/**
* Raw API response from /compliance-overviews endpoint
*/
export interface ComplianceOverviewsResponse {
data: ComplianceOverviewData[];
meta?: {
pagination?: {
page: number;
pages: number;
count: number;
};
};
}
/**
* Enriched compliance overview with computed fields
*/
export interface EnrichedComplianceOverview {
id: string;
framework: string;
version: string;
requirements_passed: number;
requirements_failed: number;
requirements_manual: number;
total_requirements: number;
score: number;
label: string;
icon: string | StaticImageData | undefined;
}
/**
* Formats framework name for display by replacing hyphens with spaces
* e.g., "FedRAMP-20x-KSI-Low" -> "FedRAMP 20x KSI Low"
*/
function formatFrameworkName(framework: string): string {
return framework.replace(/-/g, " ");
}
/**
* Adapts the raw API response to enriched compliance data
* - Computes score percentage (rounded)
* - Formats label (framework + version)
* - Resolves framework icon
* - Preserves pagination metadata
*
* @param response - Raw API response with data and optional pagination
* @returns Object with enriched compliance data and metadata
*/
export function adaptComplianceOverviewsResponse(
response: ComplianceOverviewsResponse | undefined,
): {
data: EnrichedComplianceOverview[];
metadata?: MetaDataProps;
} {
if (!response?.data) {
return { data: [] };
}
const enrichedData = response.data.map((compliance) => {
const { id, attributes } = compliance;
const {
framework,
version,
requirements_passed,
requirements_failed,
requirements_manual,
total_requirements,
} = attributes;
const totalRequirements = Number(total_requirements) || 0;
const passedRequirements = Number(requirements_passed) || 0;
const score =
totalRequirements > 0
? Math.round((passedRequirements / totalRequirements) * 100)
: 0;
const formattedFramework = formatFrameworkName(framework);
const label = version
? `${formattedFramework} - ${version}`
: formattedFramework;
const icon = getComplianceIcon(framework);
return {
id,
framework,
version,
requirements_passed,
requirements_failed,
requirements_manual,
total_requirements,
score,
label,
icon,
};
});
const metadata: MetaDataProps | undefined = response.meta?.pagination
? {
pagination: {
page: response.meta.pagination.page,
pages: response.meta.pagination.pages,
count: response.meta.pagination.count,
itemsPerPage: [10, 25, 50, 100],
},
version: "1.0",
}
: undefined;
return { data: enrichedData, metadata };
}
/**
* Sorts compliances for watchlist display:
* - Excludes ProwlerThreatScore
* - Sorted by score ascending (worst/lowest scores first)
* - Limited to specified count
*
* @param data - Enriched compliance data
* @param limit - Maximum number of items to return (default: 9)
* @returns Sorted and limited compliance data
*/
export function sortCompliancesForWatchlist(
data: EnrichedComplianceOverview[],
limit: number = 9,
): EnrichedComplianceOverview[] {
return [...data]
.filter((item) => item.framework !== "ProwlerThreatScore")
.sort((a, b) => a.score - b.score)
.slice(0, limit);
}

View File

@@ -7,22 +7,31 @@ export const getCompliancesOverview = async ({
scanId,
region,
query,
filters = {},
}: {
scanId: string;
scanId?: string;
region?: string | string[];
query?: string;
}) => {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/compliance-overviews`);
if (scanId) url.searchParams.append("filter[scan_id]", scanId);
if (query) url.searchParams.append("filter[search]", query);
const setParam = (key: string, value?: string | string[]) => {
if (!value) return;
if (region) {
const regionValue = Array.isArray(region) ? region.join(",") : region;
url.searchParams.append("filter[region__in]", regionValue);
}
const serializedValue = Array.isArray(value) ? value.join(",") : value;
if (serializedValue.trim().length > 0) {
url.searchParams.set(key, serializedValue);
}
};
Object.entries(filters).forEach(([key, value]) => setParam(key, value));
setParam("filter[scan_id]", scanId);
setParam("filter[region__in]", region);
if (query) url.searchParams.set("filter[search]", query);
try {
const response = await fetch(url.toString(), {
@@ -31,7 +40,7 @@ export const getCompliancesOverview = async ({
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching providers:", error);
console.error("Error fetching compliances overview:", error);
return undefined;
}
};

View File

@@ -1 +1,2 @@
export * from "./compliances";
export * from "./compliances.adapter";

View File

@@ -34,7 +34,7 @@ export const FindingSeverityOverTimeSSR = async ({
}
return (
<Card variant="base" className="flex h-full flex-col">
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Finding Severity Over Time</CardTitle>

View File

@@ -15,7 +15,6 @@ import {
Skeleton,
} from "@/components/shadcn";
import { calculatePercentage } from "@/lib/utils";
interface FindingsData {
total: number;
new: number;

View File

@@ -0,0 +1,33 @@
import {
adaptComplianceOverviewsResponse,
getCompliancesOverview,
} from "@/actions/compliances";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { ComplianceWatchlist } from "./compliance-watchlist";
export const ComplianceWatchlistSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const response = await getCompliancesOverview({ filters });
const { data } = adaptComplianceOverviewsResponse(response);
// Filter out ProwlerThreatScore and limit to 9 items
const items = data
.filter((item) => item.framework !== "ProwlerThreatScore")
.slice(0, 9)
.map((compliance) => ({
id: compliance.id,
framework: compliance.framework,
label: compliance.label,
icon: compliance.icon,
score: compliance.score,
}));
return <ComplianceWatchlist items={items} />;
};

View File

@@ -0,0 +1,67 @@
"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 { WatchlistCard } from "./watchlist-card";
export interface ComplianceData {
id: string;
framework: string;
label: string;
icon?: string | StaticImageData;
score: number;
}
export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
const [isAsc, setIsAsc] = useState(true);
const sortedItems = [...items]
.sort((a, b) => (isAsc ? a.score - b.score : b.score - a.score))
.map((item) => ({
key: item.id,
icon: item.icon ? (
<div className="relative size-3">
<Image
src={item.icon}
alt={`${item.framework} framework`}
fill
className="object-contain"
/>
</div>
) : (
<div className="bg-bg-data-muted size-3 rounded-sm" />
),
label: item.label,
value: `${item.score}%`,
}));
const SortIcon = isAsc ? ArrowUpNarrowWide : ArrowDownNarrowWide;
return (
<WatchlistCard
title="Compliance Watchlist"
items={sortedItems}
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>
}
emptyState={{
message: "This space is looking empty.",
description: "to add compliance frameworks to your watchlist.",
linkText: "Compliance Dashboard",
}}
/>
);
};

View File

@@ -0,0 +1,5 @@
export type { ComplianceData } from "./compliance-watchlist";
export { ComplianceWatchlist } from "./compliance-watchlist";
export { ComplianceWatchlistSSR } from "./compliance-watchlist.ssr";
export * from "./service-watchlist";
export * from "./watchlist-card";

View File

@@ -0,0 +1,66 @@
"use client";
import { getAWSIcon } from "@/components/icons/services/IconServices";
import { WatchlistCard, WatchlistItem } from "./watchlist-card";
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",
},
];
export const ServiceWatchlist = () => {
return (
<WatchlistCard
title="Service Watchlist"
items={MOCK_SERVICE_ITEMS}
ctaLabel="Services Dashboard"
ctaHref="/services"
emptyState={{
message: "This space is looking empty.",
description: "to add services to your watchlist.",
linkText: "Services Dashboard",
}}
/>
);
};

View File

@@ -0,0 +1,187 @@
import { SearchX } from "lucide-react";
import Link from "next/link";
import { ReactNode } from "react";
import { Button } from "@/components/shadcn/button/button";
import {
Card,
CardContent,
CardFooter,
CardTitle,
} from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { cn } from "@/lib/utils";
const SCORE_CONFIG = {
FAIL: {
textColor: "text-text-error-primary",
minScore: 0,
maxScore: 30,
},
WARNING: {
textColor: "text-text-warning-primary",
minScore: 31,
maxScore: 60,
},
PASS: {
textColor: "text-text-success-primary",
minScore: 61,
maxScore: 100,
},
} as const;
const getScoreTextColor = (score: number): string => {
for (const config of Object.values(SCORE_CONFIG)) {
if (score >= config.minScore && score <= config.maxScore) {
return config.textColor;
}
}
return SCORE_CONFIG.WARNING.textColor;
};
export interface WatchlistItem {
icon: ReactNode;
label: string;
key: string;
value: string | number;
}
export interface WatchlistCardProps
extends React.HTMLAttributes<HTMLDivElement> {
title: string;
items: WatchlistItem[];
ctaLabel: string;
ctaHref: string;
headerAction?: React.ReactNode;
emptyState?: {
message?: string;
description?: string;
linkText?: string;
};
}
export const WatchlistCard = ({
title,
items,
ctaLabel,
ctaHref,
headerAction,
emptyState,
}: WatchlistCardProps) => {
const isEmpty = items.length === 0;
return (
<Card
variant="base"
className="flex min-h-[405px] min-w-[328px] flex-1 flex-col justify-between md:max-w-[312px]"
>
<div className="flex items-center justify-between">
<CardTitle>{title}</CardTitle>
{headerAction}
</div>
<CardContent className="flex flex-col">
{isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center gap-12 py-6">
{/* Icon and message */}
<div className="flex flex-col items-center gap-6 pb-[18px]">
<SearchX size={64} className="text-bg-data-muted" />
<p className="text-text-neutral-tertiary w-full text-center text-sm leading-6">
{emptyState?.message || "This space is looking empty."}
</p>
</div>
{/* Description with link */}
<p className="text-text-neutral-tertiary w-full text-sm leading-6">
{emptyState?.description && (
<>
Visit the{" "}
<Button variant="link" size="link-sm" asChild>
<Link href={ctaHref}>
{emptyState.linkText || ctaLabel}
</Link>
</Button>{" "}
{emptyState.description}
</>
)}
</p>
</div>
) : (
<>
{items.map((item, index) => {
const isLast = index === items.length - 1;
// Parse numeric value if it's a percentage string (e.g., "10%")
const numericValue =
typeof item.value === "string"
? parseFloat(item.value.replace("%", ""))
: item.value;
// Get color based on score
const valueColorClass = !isNaN(numericValue)
? getScoreTextColor(numericValue)
: "text-text-neutral-tertiary";
return (
<div
key={item.key}
className={cn(
"flex h-[54px] items-center justify-between gap-2 px-3 py-[11px]",
!isLast && "border-border-neutral-tertiary border-b",
)}
>
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white">
{item.icon}
</div>
<p className="text-text-neutral-secondary flex-1 truncate text-sm leading-6">
{item.label}
</p>
<div className="flex items-center gap-1.5">
<p
className={cn(
"text-sm leading-6 font-bold",
valueColorClass,
)}
>
{item.value}
</p>
</div>
</div>
);
})}
</>
)}
</CardContent>
<CardFooter className="mb-6">
<Button variant="link" size="link-sm" asChild className="w-full">
<Link href={ctaHref}>{ctaLabel}</Link>
</Button>
</CardFooter>
</Card>
);
};
export function WatchlistCardSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[500px] min-w-[328px] flex-col md:max-w-[312px]"
>
<CardTitle>
<Skeleton className="h-7 w-[168px] rounded-xl" />
</CardTitle>
<CardContent className="flex flex-1 flex-col justify-center gap-8">
{/* 6 skeleton rows */}
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex h-7 w-full items-start gap-6">
<Skeleton className="h-7 w-[168px] rounded-xl" />
<Skeleton className="h-7 flex-1 rounded-xl" />
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -16,6 +16,11 @@ import { RiskSeverityChartSkeleton } from "./components/risk-severity-chart";
import { RiskSeverityChartSSR } from "./components/risk-severity-chart/risk-severity-chart.ssr";
import { StatusChartSkeleton } from "./components/status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./components/threat-score";
import {
ComplianceWatchlistSSR,
ServiceWatchlist,
WatchlistCardSkeleton,
} from "./components/watchlist";
export default async function NewOverviewPage({
searchParams,
@@ -50,12 +55,16 @@ export default async function NewOverviewPage({
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<div className="mt-6 flex gap-6">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<div className="mt-6 flex gap-6">
<ServiceWatchlist />
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</div>
</ContentLayout>

View File

@@ -24,6 +24,10 @@ import {
ThreatScoreSkeleton,
ThreatScoreSSR,
} from "./_new-overview/components/threat-score";
import {
ComplianceWatchlistSSR,
WatchlistCardSkeleton,
} from "./_new-overview/components/watchlist";
const FILTER_PREFIX = "filter[";
@@ -54,6 +58,10 @@ export default async function Home({
</div>
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
@@ -108,7 +116,7 @@ const SSRDataNewFindingsTable = async ({
// Expand each finding with its corresponding resource, scan, and provider
const expandedFindings = findingsData?.data
? findingsData.data.map((finding: FindingProps) => {
? (findingsData.data as FindingProps[]).map((finding) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
@@ -148,7 +156,7 @@ const SSRDataNewFindingsTable = async ({
<DataTable
key={`dashboard-${Date.now()}`}
columns={ColumnNewFindingsToDate}
data={expandedResponse?.data || []}
data={(expandedResponse?.data || []) as FindingProps[]}
// metadata={findingsData?.meta}
/>
</>

View File

@@ -36,6 +36,7 @@ const buttonVariants = cva(
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
"link-sm": "text-sm",
},
},
defaultVariants: {

View File

@@ -1,10 +1,9 @@
import { cva } from "class-variance-authority";
import { LucideIcon } from "lucide-react";
import { CardVariant } from "@/components/shadcn/card/card";
import { cn } from "@/lib/utils";
import { CardVariant } from "../card";
export interface StatItem {
icon: LucideIcon;
label: string;

View File

@@ -1,9 +1,9 @@
import { cva, type VariantProps } from "class-variance-authority";
import { LucideIcon } from "lucide-react";
import { Card, CardVariant } from "@/components/shadcn/card/card";
import { cn } from "@/lib/utils";
import { Card, CardVariant } from "../card";
import type { StatItem } from "./resource-stats-card-content";
import { ResourceStatsCardContent } from "./resource-stats-card-content";
import { ResourceStatsCardHeader } from "./resource-stats-card-header";

View File

@@ -35,6 +35,7 @@
--text-neutral-secondary: var(--color-zinc-800);
--text-neutral-tertiary: var(--color-zinc-500);
--text-error-primary: var(--color-red-600);
--text-warning-primary: var(--color-orange-500);
--text-success-primary: var(--color-green-600);
/* Border Colors */
@@ -103,6 +104,7 @@
--text-neutral-secondary: var(--color-zinc-300);
--text-neutral-tertiary: var(--color-zinc-400);
--text-error-primary: var(--color-red-500);
--text-warning-primary: var(--color-orange-500);
--text-success-primary: var(--color-green-500);
/* Border Colors */
@@ -195,7 +197,9 @@
--color-text-neutral-primary: var(--text-neutral-primary);
--color-text-neutral-secondary: var(--text-neutral-secondary);
--color-text-neutral-tertiary: var(--text-neutral-tertiary);
--color-text-error: var(--text-error-primary);
--color-text-error-primary: var(--text-error-primary);
--color-text-warning-primary: var(--text-warning-primary);
--color-text-success-primary: var(--text-success-primary);
/* Background Colors */
--color-bg-neutral-primary: var(--bg-neutral-primary);

View File

@@ -8,12 +8,20 @@ export const REQUIREMENT_STATUS = {
export type RequirementStatus =
(typeof REQUIREMENT_STATUS)[keyof typeof REQUIREMENT_STATUS];
export const COMPLIANCE_OVERVIEW_TYPE = {
OVERVIEW: "compliance-overviews",
REQUIREMENTS_STATUS: "compliance-requirements-status",
} as const;
export type ComplianceOverviewType =
(typeof COMPLIANCE_OVERVIEW_TYPE)[keyof typeof COMPLIANCE_OVERVIEW_TYPE];
export interface CompliancesOverview {
data: ComplianceOverviewData[];
}
export interface ComplianceOverviewData {
type: "compliance-requirements-status";
type: ComplianceOverviewType;
id: string;
attributes: {
framework: string;