mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
feat: implement Finding Severity Over Time chart with time range selector (#9106)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
1364
ui/AGENTS.md
1364
ui/AGENTS.md
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
186
ui/actions/overview/severity-trends.ts
Normal file
186
ui/actions/overview/severity-trends.ts
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CheckFindingsSSR } from "./check-findings.ssr";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"];
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
RiskSeverityChart,
|
||||
RiskSeverityChartSkeleton,
|
||||
} from "./risk-severity-chart";
|
||||
export { RiskSeverityChartSSR } from "./risk-severity-chart.ssr";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { StatusChart, StatusChartSkeleton } from "./status-chart";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ThreatScore, ThreatScoreSkeleton } from "./threat-score";
|
||||
export { ThreatScoreSSR } from "./threat-score.ssr";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
12
ui/app/(prowler)/new-overview/lib/filter-params.ts
Normal file
12
ui/app/(prowler)/new-overview/lib/filter-params.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
75
ui/components/graphs/shared/custom-axis-tick.tsx
Normal file
75
ui/components/graphs/shared/custom-axis-tick.tsx
Normal 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" },
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
570
ui/components/graphs/threat-map.tsx
Normal file
570
ui/components/graphs/threat-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export interface RadarDataPoint {
|
||||
category: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
severityData?: BarDataPoint[];
|
||||
}
|
||||
|
||||
export interface ScatterDataPoint {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
151
ui/ui/.husky/pre-commit
Normal 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
|
||||
Reference in New Issue
Block a user