feat: implement Finding Severity Over Time chart with time range selector (#9106)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alan Buscaglia
2025-11-12 14:33:20 +01:00
committed by GitHub
parent 98f8ef1b4b
commit 07ac96661e
54 changed files with 3190 additions and 1892 deletions

View File

@@ -64,8 +64,8 @@ You are a code reviewer for the Prowler UI project. Analyze the code changes (gi
**RULES TO CHECK:**
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const`
3. Tailwind: NO `var()` or hex colors in className → Use `className="bg-card-bg"`
4. cn(): ONLY for conditionals → NOT for static classes
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes (e.g., `bg-bg-neutral-tertiary`, `border-border-neutral-primary`)
4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")`
5. React 19: NO `useMemo`/`useCallback` without reason
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
7. File Org: 1 feature = local, 2+ features = shared
@@ -132,3 +132,20 @@ else
echo -e "${YELLOW}⏭️ Code review disabled (CODE_REVIEW_ENABLED=false)${NC}"
echo ""
fi
# Run healthcheck (typecheck and lint check)
echo -e "${BLUE}🏥 Running healthcheck...${NC}"
echo ""
cd ui || cd .
if npm run healthcheck; then
echo ""
echo -e "${GREEN}✅ Healthcheck passed${NC}"
echo ""
else
echo ""
echo -e "${RED}❌ Healthcheck failed${NC}"
echo -e "${RED}Fix type errors and linting issues before committing${NC}"
echo ""
exit 1
fi

File diff suppressed because it is too large Load Diff

View File

@@ -120,14 +120,14 @@ export const getThreatScore = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
}) => {
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
const url = new URL(`${apiBaseUrl}/overviews/threat-score`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
@@ -139,7 +139,7 @@ export const getThreatScore = async ({
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching threat score overview:", error);
console.error("Error fetching threat score:", error);
return undefined;
}
};

View File

@@ -0,0 +1,186 @@
"use server";
const TIME_RANGE_OPTIONS = {
ONE_DAY: { value: "1D", days: 1 },
FIVE_DAYS: { value: "5D", days: 5 },
ONE_WEEK: { value: "1W", days: 7 },
ONE_MONTH: { value: "1M", days: 30 },
} as const;
type TimeRange =
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS]["value"];
const getFindingsSeverityTrends = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
// TODO: Replace with actual API call when endpoint is available
// const headers = await getAuthHeaders({ contentType: false });
// const url = new URL(`${apiBaseUrl}/findings/severity/time-series`);
// Object.entries(filters).forEach(([key, value]) => {
// if (value) url.searchParams.append(key, String(value));
// });
// const response = await fetch(url.toString(), { headers });
// return handleApiResponse(response);
// Extract date range from filters to simulate different data based on selection
const startDateStr = filters["filter[inserted_at__gte]"] as
| string
| undefined;
const endDateStr = filters["filter[inserted_at__lte]"] as string | undefined;
// Generate mock data based on the date range
let mockData;
if (startDateStr && endDateStr) {
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);
const daysDiff = Math.ceil(
(endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000),
);
// Generate data points for each day in the range
const dataPoints = [];
for (let i = 0; i <= daysDiff; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(currentDate.getDate() + i);
const dateStr = currentDate.toISOString().split("T")[0];
// Vary the data based on the day for visual difference
const dayOffset = i;
dataPoints.push({
type: "severity-time-series",
id: dateStr,
date: `${dateStr}T00:00:00Z`,
informational: Math.max(0, 380 + dayOffset * 15),
low: Math.max(0, 720 + dayOffset * 20),
medium: Math.max(0, 550 + dayOffset * 10),
high: Math.max(0, 1000 - dayOffset * 5),
critical: Math.max(0, 1200 - dayOffset * 30),
muted: Math.max(0, 500 - dayOffset * 25),
});
}
mockData = {
data: dataPoints,
links: {
self: `https://api.prowler.com/api/v1/findings/severity/time-series?start=${startDateStr}&end=${endDateStr}`,
},
meta: {
date_range: `${startDateStr} to ${endDateStr}`,
days: daysDiff,
granularity: "daily",
timezone: "UTC",
},
};
} else {
// Default 5-day data if no date range provided
mockData = {
data: [
{
type: "severity-time-series",
id: "2025-10-26",
date: "2025-10-26T00:00:00Z",
informational: 420,
low: 950,
medium: 720,
high: 1150,
critical: 1350,
muted: 600,
},
{
type: "severity-time-series",
id: "2025-10-27",
date: "2025-10-27T00:00:00Z",
informational: 450,
low: 1100,
medium: 850,
high: 1300,
critical: 1500,
muted: 700,
},
{
type: "severity-time-series",
id: "2025-10-28",
date: "2025-10-28T00:00:00Z",
informational: 400,
low: 850,
medium: 650,
high: 1200,
critical: 2000,
muted: 750,
},
{
type: "severity-time-series",
id: "2025-10-29",
date: "2025-10-29T00:00:00Z",
informational: 380,
low: 720,
medium: 550,
high: 1000,
critical: 1200,
muted: 500,
},
{
type: "severity-time-series",
id: "2025-11-10",
date: "2025-11-10T00:00:00Z",
informational: 500,
low: 750,
medium: 350,
high: 1000,
critical: 550,
muted: 100,
},
],
links: {
self: "https://api.prowler.com/api/v1/findings/severity/time-series?range=5D",
},
meta: {
time_range: "5D",
granularity: "daily",
timezone: "UTC",
},
};
}
return mockData;
};
export const getSeverityTrendsByTimeRange = async ({
timeRange,
filters = {},
}: {
timeRange: TimeRange;
filters?: Record<string, string | string[] | undefined>;
}) => {
// Find the days value from TIME_RANGE_OPTIONS
const timeRangeConfig = Object.values(TIME_RANGE_OPTIONS).find(
(option) => option.value === timeRange,
);
if (!timeRangeConfig) {
throw new Error(`Invalid time range: ${timeRange}`);
}
const endDate = new Date();
const startDate = new Date(
endDate.getTime() - timeRangeConfig.days * 24 * 60 * 60 * 1000,
);
// Format dates as ISO strings for API
const startDateStr = startDate.toISOString().split("T")[0];
const endDateStr = endDate.toISOString().split("T")[0];
// Add date filters to the request
const dateFilters = {
...filters,
"filter[inserted_at__gte]": startDateStr,
"filter[inserted_at__lte]": endDateStr,
};
return getFindingsSeverityTrends({ filters: dateFilters });
};
export { getFindingsSeverityTrends };

View File

@@ -8,9 +8,7 @@ export interface CriticalRequirement {
title: string;
}
export interface SectionScores {
[sectionName: string]: number;
}
export type SectionScores = Record<string, number>;
export interface ThreatScoreSnapshotAttributes {
id: string;
@@ -18,8 +16,8 @@ export interface ThreatScoreSnapshotAttributes {
scan: string | null;
provider: string | null;
compliance_id: string;
overall_score: string; // Decimal as string from API
score_delta: string | null; // Decimal as string from API
overall_score: string;
score_delta: string | null;
section_scores: SectionScores;
critical_requirements: CriticalRequirement[];
total_requirements: number;

View File

@@ -0,0 +1,49 @@
import { getFindingsByStatus } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { StatusChart } from "../status-chart/status-chart";
export const CheckFindingsSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
if (!findingsByStatus) {
return (
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load findings data</p>
</div>
);
}
const {
fail = 0,
pass = 0,
muted_new = 0,
muted_changed = 0,
fail_new = 0,
pass_new = 0,
} = findingsByStatus?.data?.attributes || {};
const mutedTotal = muted_new + muted_changed;
return (
<StatusChart
failFindingsData={{
total: fail,
new: fail_new,
muted: mutedTotal,
}}
passFindingsData={{
total: pass,
new: pass_new,
muted: mutedTotal,
}}
/>
);
};

View File

@@ -0,0 +1 @@
export { CheckFindingsSSR } from "./check-findings.ssr";

View File

@@ -0,0 +1,38 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { FindingSeverityOverTime } from "./finding-severity-over-time";
export const FindingSeverityOverTimeDetailSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const severityTrends = await getFindingsSeverityTrends({ filters });
if (
!severityTrends ||
!severityTrends.data ||
severityTrends.data.length === 0
) {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">
Failed to load severity trends data
</p>
</div>
);
}
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary overflow-visible rounded-lg border p-4">
<h3 className="text-text-neutral-primary mb-4 text-lg font-semibold">
Finding Severity Over Time
</h3>
<FindingSeverityOverTime data={severityTrends.data} />
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import {
FindingSeverityOverTime,
FindingSeverityOverTimeSkeleton,
} from "./finding-severity-over-time";
export { FindingSeverityOverTimeSkeleton };
export const FindingSeverityOverTimeSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const severityTrends = await getFindingsSeverityTrends({ filters });
if (
!severityTrends ||
!severityTrends.data ||
severityTrends.data.length === 0
) {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">
Failed to load severity trends data
</p>
</div>
);
}
return (
<Card variant="base" className="flex h-full flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Finding Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={severityTrends.data} />
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
import { Skeleton } from "@/components/shadcn";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface SeverityDataPoint {
type: string;
id: string;
date: string;
informational: number;
low: number;
medium: number;
high: number;
critical: number;
muted?: number;
}
interface FindingSeverityOverTimeProps {
data: SeverityDataPoint[];
}
export const FindingSeverityOverTime = ({
data: initialData,
}: FindingSeverityOverTimeProps) => {
const [timeRange, setTimeRange] = useState<TimeRange>("5D");
const [data, setData] = useState<SeverityDataPoint[]>(initialData);
const [isLoading, setIsLoading] = useState(false);
const handleTimeRangeChange = async (newRange: TimeRange) => {
setTimeRange(newRange);
setIsLoading(true);
try {
const response = await getSeverityTrendsByTimeRange({
timeRange: newRange,
});
if (response?.data) {
setData(response.data);
}
} catch (error) {
console.error("Error fetching severity trends");
} finally {
setIsLoading(false);
}
};
// Transform API data into LineDataPoint format
const chartData: LineDataPoint[] = data.map((item) => {
const date = new Date(item.date);
const formattedDate = date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return {
date: formattedDate,
informational: item.informational,
low: item.low,
medium: item.medium,
high: item.high,
critical: item.critical,
...(item.muted && { muted: item.muted }),
};
});
// Define line configurations for each severity level
const lines: LineConfig[] = [
{
dataKey: "informational",
color: "var(--color-bg-data-info)",
label: "Informational",
},
{
dataKey: "low",
color: "var(--color-bg-data-low)",
label: "Low",
},
{
dataKey: "medium",
color: "var(--color-bg-data-medium)",
label: "Medium",
},
{
dataKey: "high",
color: "var(--color-bg-data-high)",
label: "High",
},
{
dataKey: "critical",
color: "var(--color-bg-data-critical)",
label: "Critical",
},
];
// Only add muted line if data contains it
if (data.some((item) => item.muted !== undefined)) {
lines.push({
dataKey: "muted",
color: "var(--color-bg-data-muted)",
label: "Muted",
});
}
return (
<>
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
<div className="mb-4 w-full">
<LineChart data={chartData} lines={lines} height={400} />
</div>
</>
);
};
export function FindingSeverityOverTimeSkeleton() {
return (
<>
<div className="mb-8 w-fit">
<div className="flex gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-12 rounded-full" />
))}
</div>
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -0,0 +1,3 @@
export { FindingSeverityOverTime } from "./finding-severity-over-time";
export { FindingSeverityOverTimeSSR } from "./finding-severity-over-time.ssr";
export { TimeRangeSelector } from "./time-range-selector";

View File

@@ -0,0 +1,60 @@
"use client";
import { cn } from "@/lib/utils";
const TIME_RANGE_OPTIONS = {
ONE_DAY: "1D",
FIVE_DAYS: "5D",
ONE_WEEK: "1W",
ONE_MONTH: "1M",
} as const;
export type TimeRange =
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS];
interface TimeRangeSelectorProps {
value: TimeRange;
onChange: (range: TimeRange) => void | Promise<void>;
isLoading?: boolean;
}
const BUTTON_STYLES = {
base: "relative inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
border: "border-r border-border-neutral-primary last:border-r-0",
text: "text-text-neutral-secondary hover:text-text-neutral-primary",
active: "data-[state=active]:text-text-neutral-primary",
underline:
"after:absolute after:bottom-1 after:left-1/2 after:h-px after:w-0 after:-translate-x-1/2 after:bg-emerald-400 after:transition-all data-[state=active]:after:w-8",
focus:
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
} as const;
export const TimeRangeSelector = ({
value,
onChange,
isLoading = false,
}: TimeRangeSelectorProps) => {
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
{Object.entries(TIME_RANGE_OPTIONS).map(([key, range]) => (
<button
key={key}
onClick={() => onChange(range as TimeRange)}
disabled={isLoading || false}
data-state={value === range ? "active" : "inactive"}
className={cn(
BUTTON_STYLES.base,
BUTTON_STYLES.border,
BUTTON_STYLES.text,
BUTTON_STYLES.active,
BUTTON_STYLES.underline,
BUTTON_STYLES.focus,
isLoading && "cursor-not-allowed opacity-50",
)}
>
{range}
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
interface GraphsTabsClientProps {
tabsContent: Record<TabId, React.ReactNode>;
}
export const GraphsTabsClient = ({ tabsContent }: GraphsTabsClientProps) => {
const [activeTab, setActiveTab] = useState<TabId>("threat-map");
const handleValueChange = (value: string) => {
setActiveTab(value as TabId);
};
return (
<Tabs
value={activeTab}
onValueChange={handleValueChange}
className="flex flex-1 flex-col"
>
<TabsList className="flex w-fit gap-2">
{GRAPH_TABS.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{GRAPH_TABS.map((tab) =>
activeTab === tab.id ? (
<TabsContent
key={tab.id}
value={tab.id}
className="mt-4 flex flex-1 overflow-visible"
>
{tabsContent[tab.id]}
</TabsContent>
) : null,
)}
</Tabs>
);
};

View File

@@ -0,0 +1,20 @@
export const GRAPH_TABS = [
{
id: "threat-map",
label: "Threat Map",
},
{
id: "risk-radar",
label: "Risk Radar",
},
{
id: "risk-pipeline",
label: "Risk Pipeline",
},
{
id: "risk-plot",
label: "Risk Plot",
},
] as const;
export type TabId = (typeof GRAPH_TABS)[number]["id"];

View File

@@ -0,0 +1,61 @@
import { Skeleton } from "@heroui/skeleton";
import { Suspense } from "react";
import { SearchParamsProps } from "@/types";
import { GraphsTabsClient } from "./graphs-tabs-client";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
import { RiskPlotView } from "./risk-plot/risk-plot-view";
import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
const LoadingFallback = () => (
<div
className="flex w-full flex-col space-y-4 rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<Skeleton
className="h-6 w-1/3 rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
<Skeleton
className="h-[457px] w-full rounded"
style={{ backgroundColor: "var(--bg-neutral-tertiary)" }}
/>
</div>
);
type GraphComponent = React.ComponentType<{ searchParams: SearchParamsProps }>;
const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
"threat-map": ThreatMapViewSSR as GraphComponent,
"risk-radar": RiskRadarViewSSR as GraphComponent,
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
"risk-plot": RiskPlotView as GraphComponent,
};
interface GraphsTabsWrapperProps {
searchParams: SearchParamsProps;
}
export const GraphsTabsWrapper = async ({
searchParams,
}: GraphsTabsWrapperProps) => {
const tabsContent = Object.fromEntries(
GRAPH_TABS.map((tab) => {
const Component = GRAPH_COMPONENTS[tab.id];
return [
tab.id,
<Suspense key={tab.id} fallback={<LoadingFallback />}>
<Component searchParams={searchParams} />
</Suspense>,
];
}),
) as Record<TabId, React.ReactNode>;
return <GraphsTabsClient tabsContent={tabsContent} />;
};

View File

@@ -0,0 +1,39 @@
import { SankeyChart } from "@/components/graphs/sankey-chart";
// Helper to simulate loading delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock data - replace with actual API call
const mockSankeyData = {
nodes: [
{ name: "AWS" },
{ name: "Azure" },
{ name: "Google Cloud" },
{ name: "Critical" },
{ name: "High" },
{ name: "Medium" },
{ name: "Low" },
],
links: [
{ source: 0, target: 3, value: 45 },
{ source: 0, target: 4, value: 120 },
{ source: 0, target: 5, value: 85 },
{ source: 1, target: 3, value: 28 },
{ source: 1, target: 4, value: 95 },
{ source: 1, target: 5, value: 62 },
{ source: 2, target: 3, value: 18 },
{ source: 2, target: 4, value: 72 },
{ source: 2, target: 5, value: 48 },
],
};
export async function RiskPipelineViewSSR() {
// TODO: Call server action to fetch sankey chart data
await delay(3000); // Simulating server action fetch time
return (
<div className="w-full flex-1 overflow-visible">
<SankeyChart data={mockSankeyData} height={460} />
</div>
);
}

View File

@@ -0,0 +1,333 @@
"use client";
import { useState } from "react";
import {
CartesianGrid,
Legend,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { AlertPill } from "@/components/graphs/shared/alert-pill";
import { ChartLegend } from "@/components/graphs/shared/chart-legend";
import {
AXIS_FONT_SIZE,
CustomXAxisTick,
} from "@/components/graphs/shared/custom-axis-tick";
import { getSeverityColorByRiskScore } from "@/components/graphs/shared/utils";
import type { BarDataPoint } from "@/components/graphs/types";
const PROVIDER_COLORS = {
AWS: "var(--color-bg-data-aws)",
Azure: "var(--color-bg-data-azure)",
Google: "var(--color-bg-data-gcp)",
};
export interface ScatterPoint {
x: number;
y: number;
provider: string;
name: string;
severityData?: BarDataPoint[];
}
interface RiskPlotClientProps {
data: ScatterPoint[];
}
interface TooltipProps {
active?: boolean;
payload?: Array<{ payload: ScatterPoint }>;
}
interface ScatterDotProps {
cx: number;
cy: number;
payload: ScatterPoint;
selectedPoint: ScatterPoint | null;
onSelectPoint: (point: ScatterPoint) => void;
allData: ScatterPoint[];
}
interface LegendProps {
payload?: Array<{ value: string; color: string }>;
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{data.name}
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
{/* Dynamic color from getSeverityColorByRiskScore - required inline style */}
<span style={{ color: severityColor, fontWeight: "bold" }}>
{data.x}
</span>{" "}
Risk Score
</p>
<div className="mt-2">
<AlertPill value={data.y} />
</div>
</div>
);
}
return null;
};
const CustomScatterDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
allData,
}: ScatterDotProps) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const selectedColor = "var(--bg-button-primary)"; // emerald-400
const fill = isSelected
? selectedColor
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
"var(--color-text-neutral-tertiary)";
const handleClick = () => {
const fullDataItem = allData?.find(
(d: ScatterPoint) => d.name === payload.name,
);
onSelectPoint?.(fullDataItem || payload);
};
return (
<g style={{ cursor: "pointer" }} onClick={handleClick}>
{isSelected && (
<>
<circle
cx={cx}
cy={cy}
r={size / 2 + 4}
fill="none"
stroke={selectedColor}
strokeWidth={1}
opacity={0.4}
/>
<circle
cx={cx}
cy={cy}
r={size / 2 + 8}
fill="none"
stroke={selectedColor}
strokeWidth={1}
opacity={0.2}
/>
</>
)}
<circle
cx={cx}
cy={cy}
r={size / 2}
fill={fill}
stroke={isSelected ? selectedColor : "transparent"}
strokeWidth={2}
/>
</g>
);
};
const CustomLegend = ({ payload }: LegendProps) => {
const items =
payload?.map((entry: { value: string; color: string }) => ({
label: entry.value,
color: entry.color,
})) || [];
return <ChartLegend items={items} />;
};
function createScatterDotShape(
selectedPoint: ScatterPoint | null,
onSelectPoint: (point: ScatterPoint) => void,
allData: ScatterPoint[],
) {
const ScatterDotShape = (props: unknown) => {
const dotProps = props as Omit<
ScatterDotProps,
"selectedPoint" | "onSelectPoint" | "allData"
>;
return (
<CustomScatterDot
{...dotProps}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
allData={allData}
/>
);
};
ScatterDotShape.displayName = "ScatterDotShape";
return ScatterDotShape;
}
export function RiskPlotClient({ data }: RiskPlotClientProps) {
const [selectedPoint, setSelectedPoint] = useState<ScatterPoint | null>(null);
const dataByProvider = data.reduce(
(acc, point) => {
const provider = point.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(point);
return acc;
},
{} as Record<string, typeof data>,
);
const handleSelectPoint = (point: ScatterPoint) => {
if (selectedPoint?.name === point.name) {
setSelectedPoint(null);
} else {
setSelectedPoint(point);
}
};
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12">
{/* Plot Section - in Card */}
<div className="flex basis-[70%] flex-col">
<div
className="flex flex-1 flex-col rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<div className="mb-4">
<h3
className="text-lg font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
Risk Plot
</h3>
</div>
<div
className="relative w-full flex-1"
style={{ minHeight: "400px" }}
>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart
margin={{ top: 20, right: 30, bottom: 60, left: 60 }}
>
<CartesianGrid
horizontal={true}
vertical={false}
strokeOpacity={1}
stroke="var(--border-neutral-secondary)"
/>
<XAxis
type="number"
dataKey="x"
name="Risk Score"
label={{
value: "Risk Score",
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={CustomXAxisTick}
tickLine={false}
domain={[0, 10]}
axisLine={false}
/>
<YAxis
type="number"
dataKey="y"
name="Failed Findings"
label={{
value: "Failed Findings",
angle: -90,
position: "left",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={{
fill: "var(--color-text-neutral-secondary)",
fontSize: AXIS_FONT_SIZE,
}}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
content={<CustomLegend />}
wrapperStyle={{ paddingTop: "40px" }}
/>
{Object.entries(dataByProvider).map(([provider, points]) => (
<Scatter
key={provider}
name={provider}
data={points}
fill={
PROVIDER_COLORS[
provider as keyof typeof PROVIDER_COLORS
] || "var(--color-text-neutral-tertiary)"
}
shape={createScatterDotShape(
selectedPoint,
handleSelectPoint,
data,
)}
/>
))}
</ScatterChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Details Section - No Card */}
<div className="flex basis-[30%] flex-col items-center justify-center overflow-hidden">
{selectedPoint && selectedPoint.severityData ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<h4
className="text-base font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
{selectedPoint.name}
</h4>
<p
className="text-xs"
style={{ color: "var(--text-neutral-tertiary)" }}
>
Risk Score: {selectedPoint.x} | Failed Findings:{" "}
{selectedPoint.y}
</p>
</div>
<HorizontalBarChart data={selectedPoint.severityData} />
</div>
) : (
<div className="flex w-full items-center justify-center text-center">
<p
className="text-sm"
style={{ color: "var(--text-neutral-tertiary)" }}
>
Select a point on the plot to view details
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { RiskPlotClient, type ScatterPoint } from "./risk-plot-client";
// Mock data - Risk Score (0-10) vs Failed Findings count
const mockScatterData: ScatterPoint[] = [
{
x: 9.2,
y: 1456,
provider: "AWS",
name: "Amazon RDS",
severityData: [
{ name: "Critical", value: 456 },
{ name: "High", value: 600 },
{ name: "Medium", value: 250 },
{ name: "Low", value: 120 },
{ name: "Info", value: 30 },
],
},
{
x: 8.5,
y: 892,
provider: "AWS",
name: "Amazon EC2",
severityData: [
{ name: "Critical", value: 280 },
{ name: "High", value: 350 },
{ name: "Medium", value: 180 },
{ name: "Low", value: 70 },
{ name: "Info", value: 12 },
],
},
{
x: 7.1,
y: 445,
provider: "AWS",
name: "Amazon S3",
severityData: [
{ name: "Critical", value: 140 },
{ name: "High", value: 180 },
{ name: "Medium", value: 90 },
{ name: "Low", value: 30 },
{ name: "Info", value: 5 },
],
},
{
x: 6.3,
y: 678,
provider: "AWS",
name: "AWS Lambda",
severityData: [
{ name: "Critical", value: 214 },
{ name: "High", value: 270 },
{ name: "Medium", value: 135 },
{ name: "Low", value: 54 },
{ name: "Info", value: 5 },
],
},
{
x: 4.2,
y: 156,
provider: "AWS",
name: "AWS Backup",
severityData: [
{ name: "Critical", value: 49 },
{ name: "High", value: 62 },
{ name: "Medium", value: 31 },
{ name: "Low", value: 12 },
{ name: "Info", value: 2 },
],
},
{
x: 8.8,
y: 1023,
provider: "Azure",
name: "Azure SQL Database",
severityData: [
{ name: "Critical", value: 323 },
{ name: "High", value: 410 },
{ name: "Medium", value: 205 },
{ name: "Low", value: 82 },
{ name: "Info", value: 3 },
],
},
{
x: 7.9,
y: 834,
provider: "Azure",
name: "Azure Virtual Machines",
severityData: [
{ name: "Critical", value: 263 },
{ name: "High", value: 334 },
{ name: "Medium", value: 167 },
{ name: "Low", value: 67 },
{ name: "Info", value: 3 },
],
},
{
x: 6.4,
y: 567,
provider: "Azure",
name: "Azure Storage",
severityData: [
{ name: "Critical", value: 179 },
{ name: "High", value: 227 },
{ name: "Medium", value: 113 },
{ name: "Low", value: 45 },
{ name: "Info", value: 3 },
],
},
{
x: 5.1,
y: 289,
provider: "Azure",
name: "Azure Key Vault",
severityData: [
{ name: "Critical", value: 91 },
{ name: "High", value: 115 },
{ name: "Medium", value: 58 },
{ name: "Low", value: 23 },
{ name: "Info", value: 2 },
],
},
{
x: 7.6,
y: 712,
provider: "Google",
name: "Cloud SQL",
severityData: [
{ name: "Critical", value: 225 },
{ name: "High", value: 285 },
{ name: "Medium", value: 142 },
{ name: "Low", value: 57 },
{ name: "Info", value: 3 },
],
},
{
x: 6.9,
y: 623,
provider: "Google",
name: "Compute Engine",
severityData: [
{ name: "Critical", value: 197 },
{ name: "High", value: 249 },
{ name: "Medium", value: 124 },
{ name: "Low", value: 50 },
{ name: "Info", value: 3 },
],
},
{
x: 5.8,
y: 412,
provider: "Google",
name: "Cloud Storage",
severityData: [
{ name: "Critical", value: 130 },
{ name: "High", value: 165 },
{ name: "Medium", value: 82 },
{ name: "Low", value: 33 },
{ name: "Info", value: 2 },
],
},
{
x: 4.5,
y: 198,
provider: "Google",
name: "Cloud Run",
severityData: [
{ name: "Critical", value: 63 },
{ name: "High", value: 79 },
{ name: "Medium", value: 39 },
{ name: "Low", value: 16 },
{ name: "Info", value: 1 },
],
},
{
x: 8.9,
y: 945,
provider: "AWS",
name: "Amazon RDS Aurora",
severityData: [
{ name: "Critical", value: 299 },
{ name: "High", value: 378 },
{ name: "Medium", value: 189 },
{ name: "Low", value: 76 },
{ name: "Info", value: 3 },
],
},
];
export function RiskPlotView() {
return <RiskPlotClient data={mockScatterData} />;
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { RadarChart } from "@/components/graphs/radar-chart";
import type { RadarDataPoint } from "@/components/graphs/types";
import { Card } from "@/components/shadcn/card/card";
interface RiskRadarViewClientProps {
data: RadarDataPoint[];
}
export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
const [selectedPoint, setSelectedPoint] = useState<RadarDataPoint | null>(
null,
);
const handleSelectPoint = (point: RadarDataPoint | null) => {
setSelectedPoint(point);
};
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12 overflow-hidden">
{/* Radar Section */}
<div className="flex basis-[70%] flex-col overflow-hidden">
<Card variant="base" className="flex flex-1 flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-neutral-primary text-lg font-semibold">
Risk Radar
</h3>
</div>
<div className="relative min-h-[400px] w-full flex-1">
<RadarChart
data={data}
height={400}
selectedPoint={selectedPoint}
onSelectPoint={handleSelectPoint}
/>
</div>
</Card>
</div>
{/* Details Section - No Card */}
<div className="flex basis-[30%] items-center overflow-hidden">
{selectedPoint && selectedPoint.severityData ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<h4 className="text-neutral-primary text-base font-semibold">
{selectedPoint.category}
</h4>
<p className="text-neutral-tertiary text-xs">
{selectedPoint.value} Total Findings
</p>
</div>
<HorizontalBarChart data={selectedPoint.severityData} />
</div>
) : (
<div className="flex w-full items-center justify-center text-center">
<p className="text-neutral-tertiary text-sm">
Select a category on the radar to view details
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import type { RadarDataPoint } from "@/components/graphs/types";
import { RiskRadarViewClient } from "./risk-radar-view-client";
// Mock data - replace with actual API call
const mockRadarData: RadarDataPoint[] = [
{
category: "Amazon Kinesis",
value: 45,
change: 2,
severityData: [
{ name: "Critical", value: 32 },
{ name: "High", value: 65 },
{ name: "Medium", value: 18 },
{ name: "Low", value: 54 },
{ name: "Info", value: 1 },
],
},
{
category: "Amazon MQ",
value: 38,
change: -1,
severityData: [
{ name: "Critical", value: 28 },
{ name: "High", value: 58 },
{ name: "Medium", value: 16 },
{ name: "Low", value: 48 },
{ name: "Info", value: 2 },
],
},
{
category: "AWS Lambda",
value: 52,
change: 5,
severityData: [
{ name: "Critical", value: 40 },
{ name: "High", value: 72 },
{ name: "Medium", value: 20 },
{ name: "Low", value: 60 },
{ name: "Info", value: 3 },
],
},
{
category: "Amazon RDS",
value: 41,
change: 3,
severityData: [
{ name: "Critical", value: 30 },
{ name: "High", value: 60 },
{ name: "Medium", value: 17 },
{ name: "Low", value: 50 },
{ name: "Info", value: 1 },
],
},
{
category: "Amazon S3",
value: 48,
change: -2,
severityData: [
{ name: "Critical", value: 36 },
{ name: "High", value: 68 },
{ name: "Medium", value: 19 },
{ name: "Low", value: 56 },
{ name: "Info", value: 2 },
],
},
{
category: "Amazon VPC",
value: 55,
change: 4,
severityData: [
{ name: "Critical", value: 42 },
{ name: "High", value: 75 },
{ name: "Medium", value: 21 },
{ name: "Low", value: 62 },
{ name: "Info", value: 3 },
],
},
];
// Helper to simulate loading delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function RiskRadarViewSSR() {
// TODO: Call server action to fetch radar chart data
await delay(3000); // Simulating server action fetch time
return <RiskRadarViewClient data={mockRadarData} />;
}

View File

@@ -0,0 +1,98 @@
import { ThreatMap } from "@/components/graphs/threat-map";
// Mock data - replace with actual API call
const mockThreatMapData = {
locations: [
{
id: "us-east-1",
name: "US East-1",
region: "North America",
coordinates: [-75.1551, 40.2206] as [number, number],
totalFindings: 455,
riskLevel: "critical" as const,
severityData: [
{ name: "Critical", value: 432 },
{ name: "High", value: 1232 },
{ name: "Medium", value: 221 },
{ name: "Low", value: 543 },
{ name: "Info", value: 10 },
],
change: 5,
},
{
id: "eu-west-1",
name: "EU West-1",
region: "Europe",
coordinates: [-6.2597, 53.3498] as [number, number],
totalFindings: 320,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 200 },
{ name: "High", value: 900 },
{ name: "Medium", value: 180 },
{ name: "Low", value: 400 },
{ name: "Info", value: 15 },
],
change: -2,
},
{
id: "ap-southeast-1",
name: "AP Southeast-1",
region: "Asia Pacific",
coordinates: [103.8198, 1.3521] as [number, number],
totalFindings: 280,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 150 },
{ name: "High", value: 800 },
{ name: "Medium", value: 160 },
{ name: "Low", value: 350 },
{ name: "Info", value: 8 },
],
change: 3,
},
{
id: "ca-central-1",
name: "CA Central-1",
region: "North America",
coordinates: [-95.7129, 56.1304] as [number, number],
totalFindings: 190,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 100 },
{ name: "High", value: 600 },
{ name: "Medium", value: 120 },
{ name: "Low", value: 280 },
{ name: "Info", value: 5 },
],
change: 1,
},
{
id: "ap-northeast-1",
name: "AP Northeast-1",
region: "Asia Pacific",
coordinates: [139.6917, 35.6895] as [number, number],
totalFindings: 240,
riskLevel: "high" as const,
severityData: [
{ name: "Critical", value: 120 },
{ name: "High", value: 700 },
{ name: "Medium", value: 140 },
{ name: "Low", value: 320 },
{ name: "Info", value: 12 },
],
change: 4,
},
],
regions: ["North America", "Europe", "Asia Pacific"],
};
export async function ThreatMapViewSSR() {
// TODO: Call server action to fetch threat map data
return (
<div className="w-full flex-1 overflow-hidden">
<ThreatMap data={mockThreatMapData} height={350} />
</div>
);
}

View File

@@ -0,0 +1,5 @@
export {
RiskSeverityChart,
RiskSeverityChartSkeleton,
} from "./risk-severity-chart";
export { RiskSeverityChartSSR } from "./risk-severity-chart.ssr";

View File

@@ -0,0 +1,41 @@
import { getFindingsBySeverity } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { RiskSeverityChart } from "./risk-severity-chart";
export const RiskSeverityChartDetailSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsBySeverity = await getFindingsBySeverity({ filters });
if (!findingsBySeverity) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load severity data</p>
</div>
);
}
const {
critical = 0,
high = 0,
medium = 0,
low = 0,
informational = 0,
} = findingsBySeverity?.data?.attributes || {};
return (
<RiskSeverityChart
critical={critical}
high={high}
medium={medium}
low={low}
informational={informational}
/>
);
};

View File

@@ -0,0 +1,14 @@
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { RiskSeverityChartDetailSSR } from "./risk-severity-chart-detail.ssr";
export const RiskSeverityChartSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
return <RiskSeverityChartDetailSSR searchParams={filters} />;
};

View File

@@ -0,0 +1 @@
export { StatusChart, StatusChartSkeleton } from "./status-chart";

View File

@@ -0,0 +1,2 @@
export { ThreatScore, ThreatScoreSkeleton } from "./threat-score";
export { ThreatScoreSSR } from "./threat-score.ssr";

View File

@@ -0,0 +1,38 @@
import { getThreatScore } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { ThreatScore } from "./threat-score";
export const ThreatScoreSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const threatScoreData = await getThreatScore({ filters });
// If no data, pass undefined score and let component handle empty state
if (!threatScoreData?.data || threatScoreData.data.length === 0) {
return <ThreatScore />;
}
// Get the first snapshot (aggregated or single provider)
const snapshot = threatScoreData.data[0];
const attributes = snapshot.attributes;
// Parse score from decimal string to number and round to integer
const score = Math.round(parseFloat(attributes.overall_score));
const scoreDelta = attributes.score_delta
? Math.round(parseFloat(attributes.score_delta))
: null;
return (
<ThreatScore
score={score}
scoreDelta={scoreDelta}
sectionScores={attributes.section_scores}
criticalRequirements={attributes.critical_requirements}
/>
);
};

View File

@@ -0,0 +1,12 @@
import { SearchParamsProps } from "@/types";
const FILTER_PREFIX = "filter[";
export function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}

View File

@@ -1,34 +1,21 @@
import { Suspense } from "react";
import {
getFindingsBySeverity,
getFindingsByStatus,
getThreatScore,
} from "@/actions/overview/overview";
import { getProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./components/accounts-selector";
import { ProviderTypeSelector } from "./components/provider-type-selector";
import { CheckFindingsSSR } from "./components/check-findings";
import {
RiskSeverityChart,
RiskSeverityChartSkeleton,
} from "./components/risk-severity-chart";
import { StatusChart, StatusChartSkeleton } from "./components/status-chart";
import { ThreatScore, ThreatScoreSkeleton } from "./components/threat-score";
const FILTER_PREFIX = "filter[";
// Extract only query params that start with "filter[" for API calls
function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
} from "./components/finding-severity-over-time/finding-severity-over-time.ssr";
import { GraphsTabsWrapper } from "./components/graphs-tabs/graphs-tabs-wrapper";
import { ProviderTypeSelector } from "./components/provider-type-selector";
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";
export default async function NewOverviewPage({
searchParams,
@@ -44,132 +31,28 @@ export default async function NewOverviewPage({
<ProviderTypeSelector providers={providersData?.data ?? []} />
<AccountsSelector providers={providersData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<SSRThreatScore searchParams={resolvedSearchParams} />
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<StatusChartSkeleton />}>
<SSRCheckFindings searchParams={resolvedSearchParams} />
<CheckFindingsSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<SSRRiskSeverityChart searchParams={resolvedSearchParams} />
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</div>
</ContentLayout>
);
}
const SSRCheckFindings = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
if (!findingsByStatus) {
return (
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load findings data</p>
</div>
);
}
const {
fail = 0,
pass = 0,
muted_new = 0,
muted_changed = 0,
fail_new = 0,
pass_new = 0,
} = findingsByStatus?.data?.attributes || {};
const mutedTotal = muted_new + muted_changed;
return (
<StatusChart
failFindingsData={{
total: fail,
new: fail_new,
muted: mutedTotal,
}}
passFindingsData={{
total: pass,
new: pass_new,
muted: mutedTotal,
}}
/>
);
};
const SSRRiskSeverityChart = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsBySeverity = await getFindingsBySeverity({ filters });
if (!findingsBySeverity) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
<p className="text-zinc-400">Failed to load severity data</p>
</div>
);
}
const {
critical = 0,
high = 0,
medium = 0,
low = 0,
informational = 0,
} = findingsBySeverity?.data?.attributes || {};
return (
<RiskSeverityChart
critical={critical}
high={high}
medium={medium}
low={low}
informational={informational}
/>
);
};
const SSRThreatScore = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const threatScoreData = await getThreatScore({ filters });
// If no data, pass undefined score and let component handle empty state
if (!threatScoreData?.data || threatScoreData.data.length === 0) {
return <ThreatScore />;
}
// Get the first snapshot (aggregated or single provider)
const snapshot = threatScoreData.data[0];
const attributes = snapshot.attributes;
// Parse score from decimal string to number and round to integer
const score = Math.round(parseFloat(attributes.overall_score));
const scoreDelta = attributes.score_delta
? Math.round(parseFloat(attributes.score_delta))
: null;
return (
<ThreatScore
score={score}
scoreDelta={scoreDelta}
sectionScores={attributes.section_scores}
criticalRequirements={attributes.critical_requirements}
/>
);
};

View File

@@ -23,15 +23,15 @@ const chartConfig = {
},
pass: {
label: "Pass",
color: "hsl(var(--chart-success))",
color: "var(--color-bg-pass)",
},
fail: {
label: "Fail",
color: "hsl(var(--chart-fail))",
color: "var(--color-bg-fail)",
},
manual: {
label: "Manual",
color: "hsl(var(--chart-warning))",
color: "var(--color-bg-warning)",
},
} satisfies ChartConfig;

View File

@@ -102,8 +102,8 @@ export function DonutChart({
{
name: "No data",
value: 1,
fill: "var(--chart-border-emphasis)",
color: "var(--chart-border-emphasis)",
fill: "var(--border-neutral-tertiary)",
color: "var(--border-neutral-tertiary)",
percentage: 0,
change: undefined,
},

View File

@@ -37,10 +37,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
<div className="w-full space-y-6">
{title && (
<div>
<h3
className="text-lg font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
<h3 className="text-text-neutral-primary text-lg font-semibold">
{title}
</h3>
</div>
@@ -59,16 +56,15 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
return (
<div
key={item.name}
className="flex gap-6"
className="flex items-center gap-10"
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
>
{/* Label */}
<div className="w-20 shrink-0">
<span
className="text-sm font-medium"
className="text-text-neutral-secondary text-sm font-medium"
style={{
color: "var(--text-neutral-secondary)",
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
@@ -138,9 +134,8 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
{/* Percentage and Count */}
<div
className="flex w-[90px] shrink-0 items-center gap-2 text-sm"
className="text-text-neutral-secondary ml-6 flex w-[90px] shrink-0 items-center gap-2 text-sm"
style={{
color: "var(--text-neutral-secondary)",
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
@@ -148,12 +143,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
<span className="w-[26px] text-right font-medium">
{isEmpty ? "0" : item.percentage}%
</span>
<span
className="font-medium"
style={{ color: "var(--text-neutral-secondary)" }}
>
</span>
<span className="font-medium"></span>
<span className="font-bold">
{isEmpty ? "0" : item.value.toLocaleString()}
</span>

View File

@@ -7,3 +7,4 @@ export { RadialChart } from "./radial-chart";
export { SankeyChart } from "./sankey-chart";
export { ScatterPlot } from "./scatter-plot";
export { ChartLegend, type ChartLegendItem } from "./shared/chart-legend";
export { ThreatMap } from "./threat-map";

View File

@@ -4,26 +4,30 @@ import { Bell } from "lucide-react";
import { useState } from "react";
import {
CartesianGrid,
Legend,
Line,
LineChart as RechartsLine,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis,
} from "recharts";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart/Chart";
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import { CHART_COLORS } from "./shared/constants";
import {
AXIS_FONT_SIZE,
CustomXAxisTickWithToday,
} from "./shared/custom-axis-tick";
import { LineConfig, LineDataPoint } from "./types";
interface LineChartProps {
data: LineDataPoint[];
lines: LineConfig[];
xLabel?: string;
yLabel?: string;
height?: number;
}
@@ -48,19 +52,8 @@ const CustomLineTooltip = ({
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
return (
<div
className="rounded-lg border p-3 shadow-lg"
style={{
backgroundColor: "var(--chart-background)",
borderColor: "var(--chart-border-emphasis)",
}}
>
<p
className="mb-3 text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
{label}
</p>
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-secondary mb-3 text-xs">{label}</p>
<div className="mb-3">
<AlertPill value={totalValue} textSize="sm" />
@@ -78,31 +71,22 @@ const CustomLineTooltip = ({
className="h-2 w-2 rounded-full"
style={{ backgroundColor: item.stroke }}
/>
<span
className="text-sm"
style={{ color: "var(--chart-text-primary)" }}
>
<span className="text-text-neutral-primary text-sm">
{item.value}
</span>
</div>
{newFindings !== undefined && (
<div className="flex items-center gap-2">
<Bell size={14} style={{ color: "var(--chart-fail)" }} />
<span
className="text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
<Bell size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-xs">
{newFindings} New Findings
</span>
</div>
)}
{change !== undefined && typeof change === "number" && (
<p
className="text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
<p className="text-text-neutral-secondary text-xs">
<span className="font-bold">
{change > 0 ? "+" : ""}
{(change as number) > 0 ? "+" : ""}
{change}%
</span>{" "}
Since Last Scan
@@ -116,96 +100,84 @@ const CustomLineTooltip = ({
);
};
const CustomLegend = ({ payload }: any) => {
const severityOrder = [
"Informational",
"Low",
"Medium",
"High",
"Critical",
"Muted",
];
const chartConfig = {
default: {
color: "var(--color-bg-data-azure)",
},
} satisfies ChartConfig;
const sortedPayload = [...payload].sort((a, b) => {
const indexA = severityOrder.indexOf(a.value);
const indexB = severityOrder.indexOf(b.value);
return indexA - indexB;
});
const items = sortedPayload.map((entry: any) => ({
label: entry.value,
color: entry.color,
}));
return <ChartLegend items={items} />;
};
export function LineChart({
data,
lines,
xLabel,
yLabel,
height = 400,
}: LineChartProps) {
export function LineChart({ data, lines, height = 400 }: LineChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
const legendItems = lines.map((line) => ({
label: line.label,
color: line.color,
}));
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsLine
data={data}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
<div className="w-full">
<ChartContainer
config={chartConfig}
className="w-full overflow-hidden"
style={{ height, aspectRatio: "auto" }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<XAxis
dataKey="date"
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<YAxis
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<Tooltip content={<CustomLineTooltip />} />
<Legend content={<CustomLegend />} />
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
return (
<Line
key={line.dataKey}
type="monotone"
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
name={line.label}
dot={{ fill: line.color, r: 4, opacity: isFaded ? 0.5 : 1 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
})}
</RechartsLine>
</ResponsiveContainer>
<RechartsLine
data={data}
margin={{
top: 10,
left: 0,
right: 8,
bottom: 20,
}}
>
<CartesianGrid
vertical={false}
strokeOpacity={1}
stroke="var(--border-neutral-secondary)"
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={CustomXAxisTickWithToday}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{
fill: "var(--color-text-neutral-secondary)",
fontSize: AXIS_FONT_SIZE,
}}
/>
<ChartTooltip cursor={false} content={<CustomLineTooltip />} />
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
return (
<Line
key={line.dataKey}
type="natural"
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
name={line.label}
dot={{ fill: line.color, r: 4 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
})}
</RechartsLine>
</ChartContainer>
<div className="mt-4">
<ChartLegend items={legendItems} />
</div>
</div>
);
}

View File

@@ -29,11 +29,11 @@ const MAP_CONFIG = {
} as const;
const MAP_COLORS = {
landFill: "var(--chart-border-emphasis)",
landStroke: "var(--chart-border)",
pointDefault: "#DB2B49",
pointSelected: "#86DA26",
pointHover: "#DB2B49",
landFill: "var(--border-neutral-tertiary)",
landStroke: "var(--border-neutral-secondary)",
pointDefault: "var(--bg-fail)",
pointSelected: "var(--bg-pass)",
pointHover: "var(--bg-fail)",
} as const;
const RISK_LEVELS = {
@@ -119,10 +119,10 @@ function MapTooltip({
position: { x: number; y: number };
}) {
const CHART_COLORS = {
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textPrimary: "var(--chart-text-primary)",
textSecondary: "var(--chart-text-secondary)",
tooltipBorder: "var(--border-neutral-tertiary)",
tooltipBackground: "var(--bg-neutral-secondary)",
textPrimary: "var(--text-neutral-primary)",
textSecondary: "var(--text-neutral-secondary)",
};
return (
@@ -169,14 +169,14 @@ function MapTooltip({
function EmptyState() {
const CHART_COLORS = {
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textSecondary: "var(--chart-text-secondary)",
tooltipBorder: "var(--border-neutral-tertiary)",
tooltipBackground: "var(--bg-neutral-secondary)",
textSecondary: "var(--text-neutral-secondary)",
};
return (
<div
className="flex h-full min-h-[400px] items-center justify-center rounded-lg border p-6"
className="flex h-full min-h-[400px] w-full items-center justify-center rounded-lg border p-6"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
@@ -198,7 +198,7 @@ function EmptyState() {
function LoadingState({ height }: { height: number }) {
const CHART_COLORS = {
textSecondary: "var(--chart-text-secondary)",
textSecondary: "var(--text-neutral-secondary)",
};
return (
@@ -382,10 +382,10 @@ export function MapChart({
]);
const CHART_COLORS = {
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textPrimary: "var(--chart-text-primary)",
textSecondary: "var(--chart-text-secondary)",
tooltipBorder: "var(--border-neutral-tertiary)",
tooltipBackground: "var(--bg-neutral-secondary)",
textPrimary: "var(--text-neutral-primary)",
textSecondary: "var(--text-neutral-secondary)",
};
return (

View File

@@ -1,5 +1,6 @@
"use client";
import { type MouseEvent } from "react";
import {
PolarAngleAxis,
PolarGrid,
@@ -14,7 +15,6 @@ import {
} from "@/components/ui/chart/Chart";
import { AlertPill } from "./shared/alert-pill";
import { CHART_COLORS } from "./shared/constants";
import { RadarDataPoint } from "./types";
interface RadarChartProps {
@@ -32,36 +32,41 @@ const chartConfig = {
},
} satisfies ChartConfig;
const CustomTooltip = ({ active, payload }: any) => {
interface TooltipPayloadItem {
payload: RadarDataPoint;
}
interface TooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0];
return (
<div
className="rounded-lg border p-3 shadow-lg"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<p
className="text-sm font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
>
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary text-sm font-semibold">
{data.payload.category}
</p>
<div className="mt-1">
<AlertPill value={data.value} />
<AlertPill value={data.payload.value} />
</div>
{data.payload.change !== undefined && (
<p
className="mt-1 text-xs"
style={{ color: CHART_COLORS.textSecondary }}
>
<span className="font-bold">
{data.payload.change > 0 ? "+" : ""}
{data.payload.change}%
</span>{" "}
Since Last Scan
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<span
style={{
color:
data.payload.change > 0
? "var(--bg-pass-primary)"
: "var(--bg-data-critical)",
fontWeight: "bold",
}}
>
{(data.payload.change as number) > 0 ? "+" : ""}
{data.payload.change}%{" "}
</span>
since last scan
</p>
)}
</div>
@@ -70,21 +75,46 @@ const CustomTooltip = ({ active, payload }: any) => {
return null;
};
const CustomDot = (props: any) => {
const { cx, cy, payload, selectedPoint, onSelectPoint } = props;
const currentCategory = payload.category || payload.name;
interface DotShapeProps {
cx: number;
cy: number;
payload: RadarDataPoint & { name?: string };
key: string;
}
interface CustomDotProps extends DotShapeProps {
selectedPoint?: RadarDataPoint | null;
onSelectPoint?: (point: RadarDataPoint | null) => void;
data?: RadarDataPoint[];
}
const CustomDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
data,
}: CustomDotProps) => {
const currentCategory = payload.name || payload.category;
const isSelected = selectedPoint?.category === currentCategory;
const handleClick = (e: React.MouseEvent) => {
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
if (onSelectPoint) {
if (isSelected) {
// Re-evaluate selection status at click time, not from closure
const currentlySelected = selectedPoint?.category === currentCategory;
if (currentlySelected) {
onSelectPoint(null);
} else {
const point = {
const fullDataItem = data?.find(
(d: RadarDataPoint) => d.category === currentCategory,
);
const point: RadarDataPoint = {
category: currentCategory,
value: payload.value,
change: payload.change,
severityData: fullDataItem?.severityData || payload.severityData,
};
onSelectPoint(point);
}
@@ -96,12 +126,11 @@ const CustomDot = (props: any) => {
cx={cx}
cy={cy}
r={isSelected ? 9 : 6}
fill={
isSelected ? "var(--chart-success-color)" : "var(--chart-radar-primary)"
}
fillOpacity={1}
className={isSelected ? "drop-shadow-[0_0_8px_#86da26]" : ""}
style={{
fill: isSelected
? "var(--bg-button-primary)"
: "var(--bg-radar-button)",
cursor: onSelectPoint ? "pointer" : "default",
pointerEvents: "all",
}}
@@ -127,30 +156,33 @@ export function RadarChart({
<ChartTooltip cursor={false} content={<CustomTooltip />} />
<PolarAngleAxis
dataKey="category"
tick={{ fill: CHART_COLORS.textPrimary }}
tick={{ fill: "var(--color-text-neutral-primary)" }}
/>
<PolarGrid strokeOpacity={0.3} />
<Radar
dataKey={dataKey}
fill="var(--chart-radar-primary)"
fillOpacity={0.2}
fill="var(--bg-radar-map)"
fillOpacity={1}
activeDot={false}
dot={
onSelectPoint
? (dotProps: any) => {
const { key, ...rest } = dotProps;
? (dotProps: DotShapeProps) => {
const { key, cx, cy, payload } = dotProps;
return (
<CustomDot
key={key}
{...rest}
cx={cx}
cy={cy}
payload={payload}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
data={data}
/>
);
}
: {
r: 6,
fill: "var(--chart-radar-primary)",
fill: "var(--bg-radar-map)",
fillOpacity: 1,
}
}

View File

@@ -1,10 +1,9 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import { ChartTooltip } from "./shared/chart-tooltip";
import { CHART_COLORS } from "./shared/constants";
interface SankeyNode {
name: string;
@@ -47,54 +46,148 @@ interface NodeTooltipState {
change?: number;
}
// Note: Using hex colors directly because Recharts SVG fill doesn't resolve CSS variables
const COLORS: Record<string, string> = {
Success: "#86da26",
Fail: "#db2b49",
AWS: "#ff9900",
Azure: "#00bcd4",
Google: "#EA4335",
Critical: "#971348",
High: "#ff3077",
Medium: "#ff7d19",
Low: "#fdd34f",
Info: "#2e51b2",
Informational: "#2e51b2",
const TOOLTIP_OFFSET_PX = 10;
// Map color names to CSS variable names defined in globals.css
const COLOR_MAP: Record<string, string> = {
Success: "--color-bg-pass",
Fail: "--color-bg-fail",
AWS: "--color-bg-data-aws",
Azure: "--color-bg-data-azure",
"Google Cloud": "--color-bg-data-gcp",
Critical: "--color-bg-data-critical",
High: "--color-bg-data-high",
Medium: "--color-bg-data-medium",
Low: "--color-bg-data-low",
Info: "--color-bg-data-info",
Informational: "--color-bg-data-info",
};
const CustomTooltip = ({ active, payload }: any) => {
/**
* Compute color value from CSS variable name at runtime.
* SVG fill attributes cannot directly resolve CSS variables,
* so we extract computed values from globals.css CSS variables.
* Falls back to black (#000000) if variable not found or access fails.
*
* @param colorName - Key in COLOR_MAP (e.g., "AWS", "Fail")
* @returns Computed CSS variable value or fallback color
*/
const getColorVariable = (colorName: string): string => {
const varName = COLOR_MAP[colorName];
if (!varName) return "#000000";
try {
if (typeof document === "undefined") {
// SSR context - return fallback
return "#000000";
}
return (
getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim() || "#000000"
);
} catch (error: unknown) {
// CSS variables not loaded or access failed - return fallback
return "#000000";
}
};
// Initialize all color variables from CSS
const initializeColors = (): Record<string, string> => {
const colors: Record<string, string> = {};
for (const [colorName] of Object.entries(COLOR_MAP)) {
colors[colorName] = getColorVariable(colorName);
}
return colors;
};
interface TooltipPayload {
payload: {
source?: { name: string };
target?: { name: string };
value?: number;
name?: string;
};
}
interface TooltipProps {
active?: boolean;
payload?: TooltipPayload[];
}
interface CustomNodeProps {
x: number;
y: number;
width: number;
height: number;
payload: SankeyNode & {
value: number;
newFindings?: number;
change?: number;
};
containerWidth: number;
colors: Record<string, string>;
onNodeHover?: (data: Omit<NodeTooltipState, "show">) => void;
onNodeMove?: (position: { x: number; y: number }) => void;
onNodeLeave?: () => void;
}
interface CustomLinkProps {
sourceX: number;
targetX: number;
sourceY: number;
targetY: number;
sourceControlX: number;
targetControlX: number;
linkWidth: number;
index: number;
payload: {
source?: { name: string };
target?: { name: string };
value?: number;
};
hoveredLink: number | null;
colors: Record<string, string>;
onLinkHover?: (index: number, data: Omit<LinkTooltipState, "show">) => void;
onLinkMove?: (position: { x: number; y: number }) => void;
onLinkLeave?: () => void;
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const sourceName = data.source?.name || data.name;
const targetName = data.target?.name;
const value = data.value;
return (
<div
className="rounded-lg border p-3 shadow-lg"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<p
className="text-sm font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
>
{data.name}
<div className="chart-tooltip">
<p className="chart-tooltip-title">
{sourceName}
{targetName ? `${targetName}` : ""}
</p>
{data.value && (
<p className="text-xs" style={{ color: CHART_COLORS.textSecondary }}>
Value: {data.value}
</p>
)}
{value && <p className="chart-tooltip-subtitle">{value}</p>}
</div>
);
}
return null;
};
const CustomNode = (props: any) => {
const { x, y, width, height, payload, containerWidth } = props;
const CustomNode = ({
x,
y,
width,
height,
payload,
containerWidth,
colors,
onNodeHover,
onNodeMove,
onNodeLeave,
}: CustomNodeProps) => {
const isOut = x + width + 6 > containerWidth;
const nodeName = payload.name;
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
@@ -104,7 +197,7 @@ const CustomNode = (props: any) => {
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
props.onNodeHover?.({
onNodeHover?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
name: nodeName,
@@ -122,7 +215,7 @@ const CustomNode = (props: any) => {
const rect = e.currentTarget.closest("svg") as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
props.onNodeMove?.({
onNodeMove?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
});
@@ -131,7 +224,7 @@ const CustomNode = (props: any) => {
const handleMouseLeave = () => {
if (!hasTooltip) return;
props.onNodeLeave?.();
onNodeLeave?.();
};
return (
@@ -156,7 +249,7 @@ const CustomNode = (props: any) => {
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2}
fontSize="14"
fill={CHART_COLORS.textPrimary}
fill="var(--color-text-neutral-primary)"
>
{nodeName}
</text>
@@ -165,7 +258,7 @@ const CustomNode = (props: any) => {
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2 + 13}
fontSize="12"
fill={CHART_COLORS.textSecondary}
fill="var(--color-text-neutral-secondary)"
>
{payload.value}
</text>
@@ -175,26 +268,30 @@ const CustomNode = (props: any) => {
);
};
const CustomLink = (props: any) => {
const {
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
index,
} = props;
const sourceName = props.payload.source?.name || "";
const targetName = props.payload.target?.name || "";
const value = props.payload.value || 0;
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
const CustomLink = ({
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
index,
payload,
hoveredLink,
colors,
onLinkHover,
onLinkMove,
onLinkLeave,
}: CustomLinkProps) => {
const sourceName = payload.source?.name || "";
const targetName = payload.target?.name || "";
const value = payload.value || 0;
const color = colors[sourceName] || "var(--color-text-neutral-tertiary)";
const isHidden = targetName === "";
const isHovered = props.hoveredLink !== null && props.hoveredLink === index;
const hasHoveredLink = props.hoveredLink !== null;
const isHovered = hoveredLink !== null && hoveredLink === index;
const hasHoveredLink = hoveredLink !== null;
const pathD = `
M${sourceX},${sourceY + linkWidth / 2}
@@ -219,7 +316,7 @@ const CustomLink = (props: any) => {
?.parentElement as unknown as SVGSVGElement;
if (rect) {
const bbox = rect.getBoundingClientRect();
props.onLinkHover?.(index, {
onLinkHover?.(index, {
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
sourceName,
@@ -235,7 +332,7 @@ const CustomLink = (props: any) => {
?.parentElement as unknown as SVGSVGElement;
if (rect && isHovered) {
const bbox = rect.getBoundingClientRect();
props.onLinkMove?.({
onLinkMove?.({
x: e.clientX - bbox.left,
y: e.clientY - bbox.top,
});
@@ -243,7 +340,7 @@ const CustomLink = (props: any) => {
};
const handleMouseLeave = () => {
props.onLinkLeave?.();
onLinkLeave?.();
};
return (
@@ -264,6 +361,7 @@ const CustomLink = (props: any) => {
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [colors, setColors] = useState<Record<string, string>>({});
const [linkTooltip, setLinkTooltip] = useState<LinkTooltipState>({
show: false,
x: 0,
@@ -283,6 +381,11 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
color: "",
});
// Initialize colors from CSS variables on mount
useEffect(() => {
setColors(initializeColors());
}, []);
const handleLinkHover = (
index: number,
data: Omit<LinkTooltipState, "show">,
@@ -320,26 +423,45 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
setNodeTooltip((prev) => ({ ...prev, show: false }));
};
// Create callback references that wrap custom props and Recharts-injected props
const wrappedCustomNode = (
props: Omit<
CustomNodeProps,
"colors" | "onNodeHover" | "onNodeMove" | "onNodeLeave"
>,
) => (
<CustomNode
{...props}
colors={colors}
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
/>
);
const wrappedCustomLink = (
props: Omit<
CustomLinkProps,
"colors" | "hoveredLink" | "onLinkHover" | "onLinkMove" | "onLinkLeave"
>,
) => (
<CustomLink
{...props}
colors={colors}
hoveredLink={hoveredLink}
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
/>
);
return (
<div className="relative">
<ResponsiveContainer width="100%" height={height}>
<Sankey
data={data}
node={
<CustomNode
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
/>
}
link={
<CustomLink
hoveredLink={hoveredLink}
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
/>
}
node={wrappedCustomNode}
link={wrappedCustomLink}
nodePadding={50}
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
sort={false}
@@ -351,9 +473,9 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
<div
className="pointer-events-none absolute z-50"
style={{
left: `${Math.max(125, Math.min(linkTooltip.x, window.innerWidth - 125))}px`,
top: `${Math.max(linkTooltip.y - 80, 10)}px`,
transform: "translate(-50%, -100%)",
left: `${Math.max(TOOLTIP_OFFSET_PX, linkTooltip.x)}px`,
top: `${Math.max(TOOLTIP_OFFSET_PX, linkTooltip.y)}px`,
transform: `translate(${TOOLTIP_OFFSET_PX}px, -100%)`,
}}
>
<ChartTooltip
@@ -376,9 +498,9 @@ export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
<div
className="pointer-events-none absolute z-50"
style={{
left: `${Math.max(125, Math.min(nodeTooltip.x, window.innerWidth - 125))}px`,
top: `${Math.max(nodeTooltip.y - 80, 10)}px`,
transform: "translate(-50%, -100%)",
left: `${Math.max(TOOLTIP_OFFSET_PX, nodeTooltip.x)}px`,
top: `${Math.max(TOOLTIP_OFFSET_PX, nodeTooltip.y)}px`,
transform: `translate(${TOOLTIP_OFFSET_PX}px, -100%)`,
}}
>
<ChartTooltip

View File

@@ -13,7 +13,6 @@ import {
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import { CHART_COLORS } from "./shared/constants";
import { getSeverityColorByRiskScore } from "./shared/utils";
import type { ScatterDataPoint } from "./types";
@@ -27,12 +26,18 @@ interface ScatterPlotProps {
}
const PROVIDER_COLORS = {
AWS: "var(--chart-provider-aws)",
Azure: "var(--chart-provider-azure)",
Google: "var(--chart-provider-google)",
AWS: "var(--color-bg-data-aws)",
Azure: "var(--color-bg-data-azure)",
Google: "var(--color-bg-data-gcp)",
Default: "var(--color-text-neutral-tertiary)",
};
const CustomTooltip = ({ active, payload }: any) => {
interface ScatterTooltipProps {
active?: boolean;
payload?: Array<{ payload: ScatterDataPoint }>;
}
const CustomTooltip = ({ active, payload }: ScatterTooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
@@ -41,19 +46,19 @@ const CustomTooltip = ({ active, payload }: any) => {
<div
className="rounded-lg border p-3 shadow-lg"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
borderColor: "var(--color-border-neutral-tertiary)",
backgroundColor: "var(--color-bg-neutral-secondary)",
}}
>
<p
className="text-sm font-semibold"
style={{ color: CHART_COLORS.textPrimary }}
style={{ color: "var(--color-text-neutral-primary)" }}
>
{data.name}
</p>
<p
className="mt-1 text-xs"
style={{ color: CHART_COLORS.textSecondary }}
style={{ color: "var(--color-text-neutral-secondary)" }}
>
<span style={{ color: severityColor }}>{data.x}</span> Risk Score
</p>
@@ -66,19 +71,27 @@ const CustomTooltip = ({ active, payload }: any) => {
return null;
};
interface ScatterDotProps {
cx: number;
cy: number;
payload: ScatterDataPoint;
selectedPoint?: ScatterDataPoint | null;
onSelectPoint?: (point: ScatterDataPoint) => void;
}
const CustomScatterDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
}: any) => {
}: ScatterDotProps) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const fill = isSelected
? "#86DA26"
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
CHART_COLORS.defaultColor;
"var(--color-text-neutral-tertiary)";
return (
<circle
@@ -95,8 +108,17 @@ const CustomScatterDot = ({
);
};
const CustomLegend = ({ payload }: any) => {
const items = payload.map((entry: any) => ({
interface LegendPayloadItem {
value: string;
color: string;
}
interface LegendProps {
payload?: LegendPayloadItem[];
}
const CustomLegend = ({ payload }: LegendProps) => {
const items = (payload || []).map((entry) => ({
label: entry.value,
color: entry.color,
}));
@@ -136,19 +158,22 @@ export function ScatterPlot({
return (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<ScatterChart margin={{ top: 20, right: 30, bottom: 60, left: 60 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--color-border-neutral-tertiary)"
/>
<XAxis
type="number"
dataKey="x"
name={xLabel}
label={{
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={{ fill: CHART_COLORS.textSecondary }}
tick={{ fill: "var(--color-text-neutral-secondary)" }}
domain={[0, 10]}
/>
<YAxis
@@ -158,10 +183,11 @@ export function ScatterPlot({
label={{
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
position: "left",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={{ fill: CHART_COLORS.textSecondary }}
tick={{ fill: "var(--color-text-neutral-secondary)" }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend content={<CustomLegend />} />
@@ -172,15 +198,18 @@ export function ScatterPlot({
data={points}
fill={
PROVIDER_COLORS[provider as keyof typeof PROVIDER_COLORS] ||
CHART_COLORS.defaultColor
PROVIDER_COLORS.Default
}
shape={(props: any) => (
<CustomScatterDot
{...props}
selectedPoint={selectedPoint}
onSelectPoint={handlePointClick}
/>
)}
shape={(props: unknown) => {
const dotProps = props as ScatterDotProps;
return (
<CustomScatterDot
{...dotProps}
selectedPoint={selectedPoint}
onSelectPoint={handlePointClick}
/>
);
}}
/>
))}
</ScatterChart>

View File

@@ -1,6 +1,6 @@
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib";
interface AlertPillProps {
value: number;
@@ -9,30 +9,41 @@ interface AlertPillProps {
textSize?: "xs" | "sm" | "base";
}
const TEXT_SIZE_CLASSES = {
sm: "text-sm",
base: "text-base",
xs: "text-xs",
} as const;
export function AlertPill({
value,
label = "Fail Findings",
iconSize = 12,
textSize = "xs",
}: AlertPillProps) {
const textSizeClass = TEXT_SIZE_CLASSES[textSize];
// Chart alert colors are theme-aware variables from globals.css
return (
<div className="flex items-center gap-2">
<div
className="flex items-center gap-1 rounded-full px-2 py-1"
style={{ backgroundColor: "var(--chart-alert-bg)" }}
style={{ backgroundColor: "var(--color-bg-fail-secondary)" }}
>
<AlertTriangle
size={iconSize}
style={{ color: "var(--chart-alert-text)" }}
style={{ color: "var(--color-text-error)" }}
/>
<span
className={cn(`text-${textSize}`, "font-semibold")}
style={{ color: "var(--chart-alert-text)" }}
className={cn(textSizeClass, "font-semibold")}
style={{ color: "var(--color-text-error)" }}
>
{value}
</span>
</div>
<span className={cn(`text-${textSize}`, "text-slate-400")}>{label}</span>
<span className="text-text-neutral-secondary text-sm font-medium">
{label}
</span>
</div>
);
}

View File

@@ -9,20 +9,17 @@ interface ChartLegendProps {
export function ChartLegend({ items }: ChartLegendProps) {
return (
<div
className="mt-4 inline-flex gap-[46px] rounded-full border-2 px-[19px] py-[9px]"
style={{ borderColor: "var(--chart-border)" }}
>
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
{items.map((item, index) => (
<div key={`legend-${index}`} className="flex items-center gap-1">
<div
key={`legend-${index}`}
className="flex items-center gap-2 px-4 py-3"
>
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: item.color }}
/>
<span
className="text-xs"
style={{ color: "var(--chart-text-secondary)" }}
>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</div>

View File

@@ -3,11 +3,18 @@ import { Bell, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { TooltipData } from "../types";
import { CHART_COLORS } from "./constants";
interface MultiSeriesPayloadEntry {
color?: string;
name?: string;
value?: string | number;
dataKey?: string;
payload?: Record<string, string | number | undefined>;
}
interface ChartTooltipProps {
active?: boolean;
payload?: any[];
payload?: MultiSeriesPayloadEntry[];
label?: string;
showColorIndicator?: boolean;
colorIndicatorShape?: "circle" | "square";
@@ -24,17 +31,11 @@ export function ChartTooltip({
return null;
}
const data: TooltipData = payload[0].payload || payload[0];
const data: TooltipData = (payload[0].payload || payload[0]) as TooltipData;
const color = payload[0].color || data.color;
return (
<div
className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800"
style={{
borderColor: CHART_COLORS.tooltipBorder,
backgroundColor: CHART_COLORS.tooltipBackground,
}}
>
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<div className="flex items-center gap-2">
{showColorIndicator && color && (
<div
@@ -45,12 +46,12 @@ export function ChartTooltip({
style={{ backgroundColor: color }}
/>
)}
<p className="text-sm font-semibold text-slate-900 dark:text-white">
<p className="text-text-neutral-primary text-sm font-semibold">
{label || data.name}
</p>
</div>
<p className="mt-1 text-xs text-slate-900 dark:text-white">
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
{typeof data.value === "number"
? data.value.toLocaleString()
: data.value}
@@ -59,8 +60,8 @@ export function ChartTooltip({
{data.newFindings !== undefined && data.newFindings > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
<Bell size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-sm font-medium">
{data.newFindings} New Findings
</span>
</div>
@@ -68,8 +69,8 @@ export function ChartTooltip({
{data.new !== undefined && data.new > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
<Bell size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-sm font-medium">
{data.new} New
</span>
</div>
@@ -77,17 +78,17 @@ export function ChartTooltip({
{data.muted !== undefined && data.muted > 0 && (
<div className="mt-1 flex items-center gap-2">
<VolumeX size={14} className="text-slate-600 dark:text-slate-400" />
<span className="text-xs text-slate-600 dark:text-slate-400">
<VolumeX size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-secondary text-sm font-medium">
{data.muted} Muted
</span>
</div>
)}
{data.change !== undefined && (
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400">
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<span className="font-bold">
{data.change > 0 ? "+" : ""}
{(data.change as number) > 0 ? "+" : ""}
{data.change}%
</span>{" "}
Since Last Scan
@@ -110,26 +111,29 @@ export function MultiSeriesChartTooltip({
}
return (
<div className="min-w-[200px] rounded-lg border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-600 dark:bg-slate-800">
<p className="mb-2 text-sm font-semibold text-slate-900 dark:text-white">
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{label}
</p>
{payload.map((entry: any, index: number) => (
{payload.map((entry: MultiSeriesPayloadEntry, index: number) => (
<div key={index} className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs text-slate-900 dark:text-white">
<span className="text-text-neutral-secondary text-sm font-medium">
{entry.name}:
</span>
<span className="text-xs font-semibold text-slate-900 dark:text-white">
<span className="text-text-neutral-secondary text-sm font-semibold">
{entry.value}
</span>
{entry.payload[`${entry.dataKey}_change`] && (
<span className="text-xs text-slate-600 dark:text-slate-400">
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
{entry.payload && entry.payload[`${entry.dataKey}_change`] && (
<span className="text-text-neutral-secondary text-sm font-medium">
(
{(entry.payload[`${entry.dataKey}_change`] as number) > 0
? "+"
: ""}
{entry.payload[`${entry.dataKey}_change`]}%)
</span>
)}

View File

@@ -1,34 +1,3 @@
export const SEVERITY_COLORS = {
Informational: "var(--bg-data-info)",
Low: "var(--bg-data-low)",
Medium: "var(--bg-data-medium)",
High: "var(--bg-data-high)",
Critical: "var(--bg-data-critical)",
} as const;
export const PROVIDER_COLORS = {
AWS: "var(--chart-provider-aws)",
Azure: "var(--chart-provider-azure)",
Google: "var(--chart-provider-google)",
} as const;
export const STATUS_COLORS = {
Success: "var(--chart-success-color)",
Fail: "var(--chart-fail)",
} as const;
export const CHART_COLORS = {
tooltipBorder: "var(--chart-border-emphasis)",
tooltipBackground: "var(--chart-background)",
textPrimary: "var(--chart-text-primary)",
textSecondary: "var(--chart-text-secondary)",
gridLine: "var(--chart-border-emphasis)",
backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity
alertPillBg: "var(--chart-alert-bg)",
alertPillText: "var(--chart-alert-text)",
defaultColor: "#64748b", // slate-500
} as const;
export const CHART_DIMENSIONS = {
defaultHeight: 400,
tooltipMinWidth: "200px",

View File

@@ -0,0 +1,75 @@
export const AXIS_FONT_SIZE = 14;
const TODAY_FONT_SIZE = 12;
interface CustomXAxisTickProps {
x: number;
y: number;
payload: {
value: string | number;
};
}
const getTodayFormatted = () => {
const today = new Date();
return today.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
};
export const CustomXAxisTickWithToday = Object.assign(
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
const { x, y, payload } = props;
const todayFormatted = getTodayFormatted();
const isToday = String(payload.value) === todayFormatted;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={20}
dy={4}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={AXIS_FONT_SIZE}
>
{payload.value}
</text>
{isToday && (
<text
x={0}
y={36}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={TODAY_FONT_SIZE}
fontWeight={400}
>
(today)
</text>
)}
</g>
);
},
{ displayName: "CustomXAxisTickWithToday" },
);
export const CustomXAxisTick = Object.assign(
function CustomXAxisTick(props: CustomXAxisTickProps) {
const { x, y, payload } = props;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={20}
dy={4}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={AXIS_FONT_SIZE}
>
{payload.value}
</text>
</g>
);
},
{ displayName: "CustomXAxisTick" },
);

View File

@@ -1,4 +1,12 @@
import { SEVERITY_COLORS } from "./constants";
const SEVERITY_COLORS = {
Critical: "var(--color-bg-data-critical)",
High: "var(--color-bg-data-high)",
Medium: "var(--color-bg-data-medium)",
Low: "var(--color-bg-data-low)",
Informational: "var(--color-bg-data-info)",
Info: "var(--color-bg-data-info)",
Muted: "var(--color-bg-data-muted)",
};
export function getSeverityColorByRiskScore(riskScore: number): string {
if (riskScore >= 7) return SEVERITY_COLORS.Critical;

View File

@@ -0,0 +1,570 @@
"use client";
import * as d3 from "d3";
import type {
Feature,
FeatureCollection,
GeoJsonProperties,
Geometry,
} from "geojson";
import { AlertTriangle, ChevronDown, Info, MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { feature } from "topojson-client";
import type {
GeometryCollection,
Objects,
Topology,
} from "topojson-specification";
import { Card } from "@/components/shadcn/card/card";
import { HorizontalBarChart } from "./horizontal-bar-chart";
import { BarDataPoint } from "./types";
// Constants
const MAP_CONFIG = {
defaultWidth: 688,
defaultHeight: 400,
pointRadius: 6,
selectedPointRadius: 8,
transitionDuration: 300,
} as const;
// SVG-specific colors: must use actual color values, not Tailwind classes
// as SVG fill/stroke attributes don't support class-based styling
// Retrieves computed CSS variable values from globals.css theme variables at runtime
// Fallback hex colors are used only when CSS variables cannot be computed (SSR context)
interface MapColorsConfig {
landFill: string;
landStroke: string;
pointDefault: string;
pointSelected: string;
pointHover: string;
}
const DEFAULT_MAP_COLORS: MapColorsConfig = {
// Fallback: gray-300 (neutral-300) - used for map land fill in light theme
landFill: "#d1d5db",
// Fallback: slate-300 - used for map borders
landStroke: "#cbd5e1",
// Fallback: red-600 - error color for points
pointDefault: "#dc2626",
// Fallback: emerald-500 - success color for selected points
pointSelected: "#10b981",
// Fallback: red-600 - error color for hover points
pointHover: "#dc2626",
};
function getMapColors(): MapColorsConfig {
if (typeof document === "undefined") return DEFAULT_MAP_COLORS;
const root = document.documentElement;
const style = getComputedStyle(root);
const getVar = (varName: string): string => {
const value = style.getPropertyValue(varName).trim();
return value && value.length > 0 ? value : "";
};
const colors: MapColorsConfig = {
landFill: getVar("--bg-neutral-map") || DEFAULT_MAP_COLORS.landFill,
landStroke:
getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke,
pointDefault:
getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointDefault,
pointSelected:
getVar("--bg-button-primary") || DEFAULT_MAP_COLORS.pointSelected,
pointHover: getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointHover,
};
return colors;
}
const RISK_LEVELS = {
LOW_HIGH: "low-high",
HIGH: "high",
CRITICAL: "critical",
} as const;
type RiskLevel = (typeof RISK_LEVELS)[keyof typeof RISK_LEVELS];
interface LocationPoint {
id: string;
name: string;
region: string;
coordinates: [number, number];
totalFindings: number;
riskLevel: RiskLevel;
severityData: BarDataPoint[];
change?: number;
}
interface ThreatMapData {
locations: LocationPoint[];
regions: string[];
}
interface ThreatMapProps {
data: ThreatMapData;
height?: number;
onLocationSelect?: (location: LocationPoint | null) => void;
}
// Utility functions
function createProjection(width: number, height: number) {
return d3
.geoNaturalEarth1()
.fitExtent(
[
[1, 1],
[width - 1, height - 1],
],
{ type: "Sphere" },
)
.precision(0.2);
}
async function fetchWorldData(): Promise<FeatureCollection | null> {
try {
const worldAtlasModule = await import("world-atlas/countries-110m.json");
const worldData = worldAtlasModule.default || worldAtlasModule;
const topology = worldData as unknown as Topology<Objects>;
return feature(
topology,
topology.objects.countries as GeometryCollection,
) as FeatureCollection;
} catch (error) {
console.error("Error loading world map data:", error);
return null;
}
}
// Helper: Create SVG element
function createSVGElement<T extends SVGElement>(
type: string,
attributes: Record<string, string>,
): T {
const element = document.createElementNS(
"http://www.w3.org/2000/svg",
type,
) as T;
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
}
// Components
function MapTooltip({
location,
position,
}: {
location: LocationPoint;
position: { x: number; y: number };
}) {
return (
<div
className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none absolute z-50 min-w-[200px] rounded-xl border p-3 shadow-lg"
style={{
left: `${position.x + 15}px`,
top: `${position.y + 15}px`,
transform: "translate(0, -50%)",
}}
>
<div className="flex items-center gap-2">
<MapPin size={14} className="text-text-neutral-secondary" />
<span className="text-text-neutral-primary text-sm font-semibold">
{location.name}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<AlertTriangle size={14} className="text-bg-data-critical" />
<span className="text-text-neutral-secondary text-sm font-medium">
{location.totalFindings.toLocaleString()} Fail Findings
</span>
</div>
{location.change !== undefined && (
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<span
className="font-bold"
style={{
color:
location.change > 0
? "var(--bg-pass-primary)"
: "var(--bg-fail-primary)",
}}
>
{location.change > 0 ? "+" : ""}
{location.change}%{" "}
</span>
since last scan
</p>
)}
</div>
);
}
function EmptyState() {
return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
<div className="text-center">
<Info size={48} className="mx-auto mb-2 text-slate-500" />
<p className="text-sm text-slate-400">
Select a location on the map to view details
</p>
</div>
</div>
);
}
function LoadingState({ height }: { height: number }) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-center">
<div className="mb-2 text-slate-400">Loading map...</div>
</div>
</div>
);
}
export function ThreatMap({
data,
height = MAP_CONFIG.defaultHeight,
}: ThreatMapProps) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [selectedLocation, setSelectedLocation] =
useState<LocationPoint | null>(null);
const [hoveredLocation, setHoveredLocation] = useState<LocationPoint | null>(
null,
);
const [tooltipPosition, setTooltipPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [selectedRegion, setSelectedRegion] = useState<string>("All Regions");
const [worldData, setWorldData] = useState<FeatureCollection | null>(null);
const [isLoadingMap, setIsLoadingMap] = useState(true);
const [dimensions, setDimensions] = useState<{
width: number;
height: number;
}>({
width: MAP_CONFIG.defaultWidth,
height,
});
const [mapColors, setMapColors] =
useState<MapColorsConfig>(DEFAULT_MAP_COLORS);
const filteredLocations =
selectedRegion === "All Regions"
? data.locations
: data.locations.filter((loc) => loc.region === selectedRegion);
// Monitor theme changes and update colors
useEffect(() => {
const updateColors = () => {
setMapColors(getMapColors());
};
// Update colors immediately
updateColors();
// Watch for theme changes (dark class on document)
const observer = new MutationObserver(() => {
updateColors();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// Fetch world data once on mount
useEffect(() => {
let isMounted = true;
fetchWorldData()
.then((data) => {
if (isMounted && data) setWorldData(data);
})
.catch(console.error)
.finally(() => {
if (isMounted) setIsLoadingMap(false);
});
return () => {
isMounted = false;
};
}, []);
// Update dimensions on resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({ width: containerRef.current.clientWidth, height });
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, [height]);
// Render the map
useEffect(() => {
if (!svgRef.current || !worldData || isLoadingMap) return;
const svg = svgRef.current;
const { width, height } = dimensions;
svg.innerHTML = "";
const projection = createProjection(width, height);
const path = d3.geoPath().projection(projection);
const colors = mapColors;
// Render countries
const mapGroup = createSVGElement<SVGGElement>("g", {
class: "map-countries",
});
worldData.features?.forEach(
(feature: Feature<Geometry, GeoJsonProperties>) => {
const pathData = path(feature);
if (pathData) {
const pathElement = createSVGElement<SVGPathElement>("path", {
d: pathData,
fill: colors.landFill,
stroke: colors.landStroke,
"stroke-width": "0.5",
});
mapGroup.appendChild(pathElement);
}
},
);
svg.appendChild(mapGroup);
// Helper to update tooltip position
const updateTooltip = (e: MouseEvent) => {
const rect = svg.getBoundingClientRect();
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
// Helper to create glow rings
const createGlowRing = (
cx: string,
cy: string,
radiusOffset: number,
color: string,
opacity: string,
): SVGCircleElement => {
return createSVGElement<SVGCircleElement>("circle", {
cx,
cy,
r: radiusOffset.toString(),
fill: "none",
stroke: color,
"stroke-width": "1",
opacity,
});
};
// Helper to create circle with glow
const createCircle = (location: LocationPoint) => {
const projected = projection(location.coordinates);
if (!projected) return null;
const [x, y] = projected;
if (x < 0 || x > width || y < 0 || y > height) return null;
const isSelected = selectedLocation?.id === location.id;
const isHovered = hoveredLocation?.id === location.id;
const group = createSVGElement<SVGGElement>("g", {
class: "cursor-pointer",
});
const radius = isSelected
? MAP_CONFIG.selectedPointRadius
: MAP_CONFIG.pointRadius;
const color = isSelected ? colors.pointSelected : colors.pointDefault;
// Add glow rings for all points (unselected and selected)
group.appendChild(
createGlowRing(x.toString(), y.toString(), radius + 4, color, "0.4"),
);
group.appendChild(
createGlowRing(x.toString(), y.toString(), radius + 8, color, "0.2"),
);
const circle = createSVGElement<SVGCircleElement>("circle", {
cx: x.toString(),
cy: y.toString(),
r: radius.toString(),
fill: color,
class: isHovered && !isSelected ? "opacity-70" : "",
});
group.appendChild(circle);
group.addEventListener("click", () =>
setSelectedLocation(isSelected ? null : location),
);
group.addEventListener("mouseenter", (e) => {
setHoveredLocation(location);
updateTooltip(e);
});
group.addEventListener("mousemove", updateTooltip);
group.addEventListener("mouseleave", () => {
setHoveredLocation(null);
setTooltipPosition(null);
});
return group;
};
// Render points
const pointsGroup = createSVGElement<SVGGElement>("g", {
class: "threat-points",
});
// Unselected points first
filteredLocations.forEach((location) => {
if (selectedLocation?.id !== location.id) {
const circle = createCircle(location);
if (circle) pointsGroup.appendChild(circle);
}
});
// Selected point last (on top)
if (selectedLocation) {
const selectedData = filteredLocations.find(
(loc) => loc.id === selectedLocation.id,
);
if (selectedData) {
const circle = createCircle(selectedData);
if (circle) pointsGroup.appendChild(circle);
}
}
svg.appendChild(pointsGroup);
}, [
dimensions,
filteredLocations,
selectedLocation,
hoveredLocation,
worldData,
isLoadingMap,
mapColors,
]);
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12 overflow-hidden">
{/* Map Section - in Card */}
<div className="flex basis-[70%] flex-col overflow-hidden">
<Card
ref={containerRef}
variant="base"
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-text-neutral-primary text-lg font-semibold">
Threat Map
</h3>
<div className="relative">
<select
aria-label="Filter threat map by region"
value={selectedRegion}
onChange={(e) => setSelectedRegion(e.target.value)}
className="border-border-neutral-primary bg-bg-neutral-secondary text-text-neutral-primary appearance-none rounded-lg border px-4 py-2 pr-10 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="All Regions">All Regions</option>
{data.regions.map((region) => (
<option key={region} value={region}>
{region}
</option>
))}
</select>
<ChevronDown
size={16}
className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 right-3 -translate-y-1/2"
/>
</div>
</div>
<div className="relative w-full flex-1">
{isLoadingMap ? (
<LoadingState height={dimensions.height} />
) : (
<>
<div className="relative h-full w-full">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="h-full w-full"
style={{ maxWidth: "100%", maxHeight: "100%" }}
preserveAspectRatio="xMidYMid meet"
/>
{hoveredLocation && tooltipPosition && (
<MapTooltip
location={hoveredLocation}
position={tooltipPosition}
/>
)}
</div>
<div
className="mt-3 flex items-center gap-2"
role="status"
aria-label={`${filteredLocations.length} threat locations on map`}
>
<div
aria-hidden="true"
className="h-3 w-3 rounded-full"
style={{ backgroundColor: "var(--bg-data-critical)" }}
/>
<span
className="text-sm"
style={{ color: "var(--text-neutral-tertiary)" }}
>
{filteredLocations.length} Locations
</span>
</div>
</>
)}
</div>
</Card>
</div>
{/* Details Section - No Card */}
<div className="flex basis-[30%] items-center overflow-hidden">
{selectedLocation ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<div
className="mb-1 flex items-center gap-2"
aria-label={`Selected location: ${selectedLocation.name}`}
>
<div
aria-hidden="true"
className="bg-pass-primary h-2 w-2 rounded-full"
/>
<h4 className="text-neutral-primary text-base font-semibold">
{selectedLocation.name}
</h4>
</div>
<p className="text-neutral-tertiary text-xs">
{selectedLocation.totalFindings.toLocaleString()} Total
Findings
</p>
</div>
<HorizontalBarChart data={selectedLocation.severityData} />
</div>
) : (
<EmptyState />
)}
</div>
</div>
</div>
);
}

View File

@@ -34,6 +34,7 @@ export interface RadarDataPoint {
category: string;
value: number;
change?: number;
severityData?: BarDataPoint[];
}
export interface ScatterDataPoint {

View File

@@ -23,27 +23,27 @@ export interface ChartConfig {
const chartConfig = {
critical: {
label: "Critical",
color: "hsl(var(--chart-critical))",
color: "var(--color-bg-data-critical)",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=critical",
},
high: {
label: "High",
color: "hsl(var(--chart-fail))",
color: "var(--color-bg-data-high)",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=high",
},
medium: {
label: "Medium",
color: "hsl(var(--chart-medium))",
color: "var(--color-bg-data-medium)",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=medium",
},
low: {
label: "Low",
color: "hsl(var(--chart-low))",
color: "var(--color-bg-data-low)",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=low",
},
informational: {
label: "Informational",
color: "hsl(var(--chart-informational))",
color: "var(--color-bg-data-info)",
link: "/findings?filter%5Bstatus__in%5D=FAIL&filter%5Bseverity__in%5D=informational",
},
} satisfies ChartConfig;

View File

@@ -49,15 +49,15 @@ const chartConfig = {
},
success: {
label: "Success",
color: "hsl(var(--chart-success))",
color: "var(--color-bg-pass)",
},
fail: {
label: "Fail",
color: "hsl(var(--chart-fail))",
color: "var(--color-bg-fail)",
},
muted: {
label: "Muted",
color: "hsl(var(--chart-muted))",
color: "var(--color-bg-neutral-tertiary)",
},
} satisfies ChartConfig;

View File

@@ -8,9 +8,9 @@ const TRIGGER_STYLES = {
active:
"data-[state=active]:text-slate-900 dark:data-[state=active]:text-white",
underline:
"after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-[#20B853] after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]",
"after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-emerald-400 after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]",
focus:
"focus-visible:ring-2 focus-visible:ring-[#20B853] focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
"focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
} as const;

View File

@@ -5,59 +5,37 @@
/* ===== LIGHT THEME (ROOT) ===== */
:root {
/* ===== LEGACY VARIABLES (CHART COLORS) ===== */
--chart-info: #3c8dff;
--chart-warning: #fdfbd4;
--chart-warning-emphasis: #fec94d;
--chart-danger: #f77852;
--chart-danger-emphasis: #ff006a;
--chart-success-color: #16a34a;
--chart-fail: #dc2626;
--chart-radar-primary: #9d174d;
--chart-text-primary: #1f2937;
--chart-text-secondary: #6b7280;
--chart-border: #d1d5db;
--chart-border-emphasis: #9ca3af;
--chart-background: #f9fafb;
--chart-alert-bg: #fecdd3;
--chart-alert-text: #be123c;
/* Chart HSL values */
--chart-success: 146 80% 35%;
--chart-fail: 339 90% 51%;
--chart-muted: 45 93% 47%;
--chart-critical: 336 75% 39%;
--chart-high: 339 90% 51%;
--chart-medium: 26 100% 55%;
--chart-low: 46 97% 65%;
--chart-1: 12 76% 61%;
--chart-2: 160 60% 45%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
/* ===== NEW VARIABLES (PROWLER-307) - SEMANTIC COLORS ===== */
/* Data Background Colors */
--bg-data-azure: var(--color-sky-400);
--bg-data-kubernetes: var(--color-indigo-600);
--bg-data-aws: var(--color-amber-500);
--bg-data-gcp: var(--color-red-500);
--bg-data-m365: var(--color-green-400);
--bg-data-github: var(--color-slate-950);
/* Button Colors */
--bg-button-primary: var(--color-emerald-400);
--bg-button-primary: var(--color-emerald-300);
--bg-button-primary-hover: var(--color-teal-200);
--bg-button-primary-press: var(--color-emerald-400);
--bg-button-secondary: var(--color-slate-950);
--bg-button-secondary-press: var(--color-indigo-100);
--bg-button-secondary-press: var(--color-slate-800);
--bg-button-tertiary: var(--color-blue-600);
--bg-button-tertiary-hover: var(--color-blue-500);
--bg-button-tertiary-active: var(--color-indigo-600);
--bg-button-disabled: var(--color-gray-300);
--bg-button-disabled: var(--color-neutral-300);
/* Radar Map */
--bg-radar-map: #b51c8033;
--bg-radar-button: #b51c80;
/* Neutral Map */
--bg-neutral-map: var(--color-neutral-300);
/* Input Colors */
--bg-input-primary: var(--color-white);
--border-input-primary: var(--color-slate-600);
--border-input-primary: var(--color-slate-400);
--border-input-primary-press: var(--color-slate-700);
--border-input-primary-fill: var(--color-slate-500);
/* Text Colors */
--text-neutral-primary: var(--color-slate-950);
--text-neutral-secondary: var(--color-zinc-800);
--text-neutral-tertiary: var(--color-zinc-500);
--text-error-primary: var(--color-red-600);
--text-success-primary: var(--color-green-600);
/* Border Colors */
--border-error-primary: var(--color-red-500);
@@ -67,12 +45,6 @@
--border-tag-primary: var(--color-gray-400);
--border-data-emphasis: rgba(0, 0, 0, 0.1);
/* Text Colors */
--text-neutral-primary: var(--color-slate-950);
--text-neutral-secondary: var(--color-zinc-800);
--text-neutral-tertiary: var(--color-zinc-500);
--text-error-primary: var(--color-red-600);
/* Background Colors */
--bg-neutral-primary: #fdfdfd;
--bg-neutral-secondary: var(--color-white);
@@ -84,12 +56,21 @@
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: var(--color-rose-50);
/* Data Background Colors */
--bg-data-azure: var(--color-sky-400);
--bg-data-kubernetes: var(--color-indigo-600);
--bg-data-aws: var(--color-amber-500);
--bg-data-gcp: var(--color-red-500);
--bg-data-m365: var(--color-green-400);
--bg-data-github: var(--color-slate-950);
/* Severity Colors */
--bg-data-critical: #ff006a;
--bg-data-high: #f77852;
--bg-data-medium: #fdd34f;
--bg-data-low: #f5f3ce;
--bg-data-info: #3c8dff;
--bg-data-muted: var(--color-neutral-500);
/* Chart Dots */
--chart-dots: var(--color-neutral-200);
@@ -97,38 +78,53 @@
/* ===== DARK THEME ===== */
.dark {
/* ===== LEGACY VARIABLES (CHART COLORS) ===== */
--chart-info: #3c8dff;
--chart-warning: #fdfbd4;
--chart-warning-emphasis: #fec94d;
--chart-danger: #f77852;
--chart-danger-emphasis: #ff006a;
--chart-success-color: #86da26;
--chart-fail: #db2b49;
--chart-radar-primary: #b51c80;
--chart-text-primary: #ffffff;
--chart-text-secondary: #94a3b8;
--chart-border: #475569;
--chart-border-emphasis: #334155;
--chart-background: #1e293b;
--chart-alert-bg: #432232;
--chart-alert-text: #f54280;
/* Chart HSL values */
--chart-success: 146 80% 35%;
--chart-fail: 339 90% 51%;
--chart-muted: 45 93% 47%;
--chart-critical: 336 75% 39%;
--chart-high: 339 90% 51%;
--chart-medium: 26 100% 55%;
--chart-low: 46 97% 65%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* ===== NEW VARIABLES (PROWLER-307) - SEMANTIC COLORS ===== */
/* Button Colors */
--bg-button-primary: var(--color-emerald-300);
--bg-button-primary-hover: var(--color-teal-200);
--bg-button-primary-press: var(--color-emerald-400);
--bg-button-secondary: var(--color-white);
--bg-button-secondary-press: var(--color-emerald-100);
--bg-button-tertiary: var(--color-blue-300);
--bg-button-tertiary-hover: var(--color-blue-400);
--bg-button-tertiary-active: var(--color-blue-600);
--bg-button-disabled: var(--color-neutral-700);
/* Neutral Map */
--bg-neutral-map: var(--color-gray-800);
/* Input Colors */
--bg-input-primary: var(--color-neutral-900);
--border-input-primary: var(--color-neutral-800);
--border-input-primary-press: var(--color-neutral-800);
--border-input-primary-fill: var(--color-neutral-800);
/* Text Colors */
--text-neutral-primary: var(--color-zinc-100);
--text-neutral-secondary: var(--color-zinc-300);
--text-neutral-tertiary: var(--color-zinc-400);
--text-error-primary: var(--color-red-500);
--text-success-primary: var(--color-green-500);
/* Border Colors */
--border-error-primary: var(--color-red-400);
--border-neutral-primary: var(--color-zinc-800);
--border-neutral-secondary: var(--color-zinc-900);
--border-neutral-tertiary: var(--color-zinc-900);
--border-tag-primary: var(--color-slate-700);
--border-data-emphasis: rgba(255, 255, 255, 0.1);
/* Background Colors */
--bg-neutral-primary: var(--color-zinc-950);
--bg-neutral-secondary: var(--color-stone-950);
--bg-neutral-tertiary: #121110;
--bg-tag-primary: var(--color-slate-950);
--bg-pass-primary: var(--color-green-400);
--bg-pass-secondary: var(--color-emerald-900);
--bg-warning-primary: var(--color-orange-400);
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: #432232;
/* Data Background Colors */
--bg-data-azure: var(--color-sky-400);
--bg-data-kubernetes: var(--color-indigo-600);
@@ -137,51 +133,13 @@
--bg-data-m365: var(--color-green-400);
--bg-data-github: var(--color-neutral-100);
/* Button Colors */
--bg-button-primary: var(--color-emerald-400);
--bg-button-primary-hover: var(--color-teal-200);
--bg-button-secondary: var(--color-slate-700);
--bg-button-secondary-press: var(--color-slate-800);
--bg-button-tertiary: var(--color-blue-500);
--bg-button-tertiary-hover: var(--color-blue-700);
--bg-button-tertiary-active: var(--color-blue-800);
--bg-button-disabled: var(--color-gray-600);
/* Input Colors */
--bg-input-primary: var(--color-slate-900);
--border-input-primary: var(--color-slate-700);
/* Border Colors */
--border-error-primary: var(--color-rose-500);
--border-neutral-primary: var(--color-zinc-800);
--border-neutral-secondary: var(--color-zinc-900);
--border-neutral-tertiary: var(--color-zinc-900);
--border-tag-primary: var(--color-slate-700);
--border-data-emphasis: rgba(255, 255, 255, 0.1);
/* Text Colors */
--text-neutral-primary: var(--color-zinc-100);
--text-neutral-secondary: var(--color-zinc-300);
--text-neutral-tertiary: var(--color-zinc-500);
--text-error-primary: var(--color-rose-300);
/* Background Colors */
--bg-neutral-primary: var(--color-zinc-950);
--bg-neutral-secondary: var(--color-stone-950);
--bg-neutral-tertiary: #121110;
--bg-tag-primary: var(--color-slate-950);
--bg-warning-primary: var(--color-orange-400);
--bg-pass-primary: var(--color-green-400);
--bg-pass-secondary: var(--color-emerald-900);
--bg-fail-primary: var(--color-rose-500);
--bg-fail-secondary: #432232;
/* Severity Colors */
--bg-data-critical: #ff006a;
--bg-data-high: #f77852;
--bg-data-medium: #fec94d;
--bg-data-low: #fdfbd4;
--bg-data-info: #3c8dff;
--bg-data-muted: var(--color-neutral-500);
/* Chart Dots */
--chart-dots: var(--text-neutral-primary);
@@ -220,10 +178,12 @@
--color-bg-data-medium: var(--bg-data-medium);
--color-bg-data-low: var(--bg-data-low);
--color-bg-data-info: var(--bg-data-info);
--color-bg-data-muted: var(--bg-data-muted);
/* Button Colors */
--color-button-primary: var(--bg-button-primary);
--color-button-primary-hover: var(--bg-button-primary-hover);
--color-button-primary-press: var(--bg-button-primary-press);
--color-button-secondary: var(--bg-button-secondary);
--color-button-secondary-press: var(--bg-button-secondary-press);
--color-button-tertiary: var(--bg-button-tertiary);
@@ -234,6 +194,14 @@
/* Input Colors */
--color-input-primary: var(--bg-input-primary);
--color-input-border: var(--border-input-primary);
--color-input-border-press: var(--border-input-primary-press);
--color-input-border-fill: var(--border-input-primary-fill);
/* Neutral Map Colors */
--color-bg-neutral-map: var(--bg-neutral-map);
/* Success Colors */
--color-text-success: var(--text-success-primary);
/* Border Colors */
--color-border-error: var(--border-error-primary);

151
ui/ui/.husky/pre-commit Normal file
View File

@@ -0,0 +1,151 @@
#!/bin/bash
# Prowler UI - Pre-Commit Hook
# Optionally validates ONLY staged files against AGENTS.md standards using Claude Code
# Controlled by CODE_REVIEW_ENABLED in .env
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 Prowler UI - Pre-Commit Hook"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Load .env file (look in git root directory)
GIT_ROOT=$(git rev-parse --show-toplevel)
if [ -f "$GIT_ROOT/ui/.env" ]; then
CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" "$GIT_ROOT/ui/.env" | cut -d'=' -f2 | tr -d ' ')
elif [ -f "$GIT_ROOT/.env" ]; then
CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" "$GIT_ROOT/.env" | cut -d'=' -f2 | tr -d ' ')
elif [ -f ".env" ]; then
CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" .env | cut -d'=' -f2 | tr -d ' ')
else
CODE_REVIEW_ENABLED="false"
fi
# Normalize the value to lowercase
CODE_REVIEW_ENABLED=$(echo "$CODE_REVIEW_ENABLED" | tr '[:upper:]' '[:lower:]')
echo -e "${BLUE} Code Review Status: ${CODE_REVIEW_ENABLED}${NC}"
echo ""
# Get staged files (what will be committed)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(tsx?|jsx?)$' || true)
if [ "$CODE_REVIEW_ENABLED" = "true" ]; then
if [ -z "$STAGED_FILES" ]; then
echo -e "${YELLOW}⚠️ No TypeScript/JavaScript files staged to validate${NC}"
echo ""
else
echo -e "${YELLOW}🔍 Running Claude Code standards validation...${NC}"
echo ""
echo -e "${BLUE}📋 Files to validate:${NC}"
echo "$STAGED_FILES" | sed 's/^/ - /'
echo ""
echo -e "${BLUE}📤 Sending to Claude Code for validation...${NC}"
echo ""
# Build prompt with git diff of changes AND full context
VALIDATION_PROMPT=$(
cat <<'PROMPT_EOF'
You are a code reviewer for the Prowler UI project. Analyze the code changes (git diff with full context) below and validate they comply with AGENTS.md standards.
**CRITICAL: You MUST check BOTH the changed lines AND the surrounding context for violations.**
**RULES TO CHECK:**
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const`
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes (e.g., `bg-bg-neutral-tertiary`, `border-border-neutral-primary`)
4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")`
5. React 19: NO `useMemo`/`useCallback` without reason
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
7. File Org: 1 feature = local, 2+ features = shared
8. Directives: Server Actions need "use server", clients need "use client"
9. Implement DRY, KISS principles. (example: reusable components, avoid repetition)
10. Layout must work for all the responsive breakpoints (mobile, tablet, desktop)
11. ANY types cannot be used - CRITICAL: Check for `: any` in all visible lines
12. Use the components inside components/shadcn if possible
13. Check Accessibility best practices (like alt tags in images, semantic HTML, Aria labels, etc.)
=== GIT DIFF WITH CONTEXT ===
PROMPT_EOF
)
# Add git diff to prompt with more context (U5 = 5 lines before/after)
VALIDATION_PROMPT="$VALIDATION_PROMPT
$(git diff --cached -U5)"
VALIDATION_PROMPT="$VALIDATION_PROMPT
=== END DIFF ===
**IMPORTANT: Your response MUST start with exactly one of these lines:**
STATUS: PASSED
STATUS: FAILED
**If FAILED:** List each violation with File, Line Number, Rule Number, and Issue.
**If PASSED:** Confirm all visible code (including context) complies with AGENTS.md standards.
**Start your response now with STATUS:**"
# Send to Claude Code
if VALIDATION_OUTPUT=$(echo "$VALIDATION_PROMPT" | claude 2>&1); then
echo "$VALIDATION_OUTPUT"
echo ""
# Check result - STRICT MODE: fail if status unclear
if echo "$VALIDATION_OUTPUT" | grep -q "^STATUS: PASSED"; then
echo ""
echo -e "${GREEN}✅ VALIDATION PASSED${NC}"
echo ""
elif echo "$VALIDATION_OUTPUT" | grep -q "^STATUS: FAILED"; then
echo ""
echo -e "${RED}❌ VALIDATION FAILED${NC}"
echo -e "${RED}Fix violations before committing${NC}"
echo ""
exit 1
else
echo ""
echo -e "${RED}❌ VALIDATION ERROR${NC}"
echo -e "${RED}Could not determine validation status from Claude Code response${NC}"
echo -e "${YELLOW}Response must start with 'STATUS: PASSED' or 'STATUS: FAILED'${NC}"
echo ""
echo -e "${YELLOW}To bypass validation temporarily, set CODE_REVIEW_ENABLED=false in .env${NC}"
echo ""
exit 1
fi
else
echo -e "${YELLOW}⚠️ Claude Code not available${NC}"
fi
echo ""
fi
else
echo -e "${YELLOW}⏭️ Code review disabled (CODE_REVIEW_ENABLED=false)${NC}"
echo ""
fi
# Run healthcheck (typecheck and lint check)
echo -e "${BLUE}🏥 Running healthcheck...${NC}"
echo ""
cd ui || cd .
if npm run healthcheck; then
echo ""
echo -e "${GREEN}✅ Healthcheck passed${NC}"
echo ""
else
echo ""
echo -e "${RED}❌ Healthcheck failed${NC}"
echo -e "${RED}Fix type errors and linting issues before committing${NC}"
echo ""
exit 1
fi