mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat: reusable graph components (#8873)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
This commit is contained in:
162
ui/components/graphs/BarChart.tsx
Normal file
162
ui/components/graphs/BarChart.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartTooltip } from "./shared/ChartTooltip";
|
||||
import { CHART_COLORS, LAYOUT_OPTIONS } from "./shared/constants";
|
||||
import { getSeverityColorByName } from "./shared/utils";
|
||||
import { BarDataPoint, LayoutOption } from "./types";
|
||||
|
||||
interface BarChartProps {
|
||||
data: BarDataPoint[];
|
||||
layout?: LayoutOption;
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
showValues?: boolean;
|
||||
}
|
||||
|
||||
const CustomLabel = ({ x, y, width, height, value, data }: any) => {
|
||||
const percentage = data.percentage;
|
||||
return (
|
||||
<text
|
||||
x={x + width + 10}
|
||||
y={y + height / 2}
|
||||
fill={CHART_COLORS.textSecondary}
|
||||
fontSize={12}
|
||||
textAnchor="start"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{percentage !== undefined
|
||||
? `${percentage}% • ${value.toLocaleString()}`
|
||||
: value.toLocaleString()}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
layout = LAYOUT_OPTIONS.horizontal,
|
||||
xLabel,
|
||||
yLabel,
|
||||
height = 400,
|
||||
showValues = true,
|
||||
}: BarChartProps) {
|
||||
const isHorizontal = layout === LAYOUT_OPTIONS.horizontal;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsBar
|
||||
data={data}
|
||||
layout={layout}
|
||||
margin={{ top: 20, right: showValues ? 100 : 30, left: 20, bottom: 20 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={CHART_COLORS.gridLine}
|
||||
horizontal={isHorizontal}
|
||||
vertical={!isHorizontal}
|
||||
/>
|
||||
{isHorizontal ? (
|
||||
<>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
xLabel
|
||||
? {
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={100}
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
yLabel
|
||||
? {
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
xLabel
|
||||
? {
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
|
||||
label={
|
||||
yLabel
|
||||
? {
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={4}
|
||||
label={
|
||||
showValues && isHorizontal
|
||||
? (props: any) => (
|
||||
<CustomLabel {...props} data={data[props.index]} />
|
||||
)
|
||||
: false
|
||||
}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
entry.color ||
|
||||
getSeverityColorByName(entry.name) ||
|
||||
CHART_COLORS.defaultColor
|
||||
}
|
||||
opacity={1}
|
||||
className="transition-opacity hover:opacity-80"
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBar>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
171
ui/components/graphs/DonutChart.tsx
Normal file
171
ui/components/graphs/DonutChart.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
import { ChartLegend } from "./shared/ChartLegend";
|
||||
import { DonutDataPoint } from "./types";
|
||||
|
||||
interface DonutChartProps {
|
||||
data: DonutDataPoint[];
|
||||
height?: number;
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
showLegend?: boolean;
|
||||
centerLabel?: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{data.percentage}% {data.name}
|
||||
</span>
|
||||
</div>
|
||||
{data.change !== undefined && (
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{data.change > 0 ? "+" : ""}
|
||||
{data.change}%
|
||||
</span>{" "}
|
||||
Since last scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const items = payload.map((entry: any) => ({
|
||||
label: `${entry.value} (${entry.payload.percentage}%)`,
|
||||
color: entry.color,
|
||||
}));
|
||||
|
||||
return <ChartLegend items={items} />;
|
||||
};
|
||||
|
||||
export function DonutChart({
|
||||
data,
|
||||
innerRadius = 80,
|
||||
outerRadius = 120,
|
||||
showLegend = true,
|
||||
centerLabel,
|
||||
}: DonutChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const chartConfig = data.reduce(
|
||||
(config, item) => ({
|
||||
...config,
|
||||
[item.name]: {
|
||||
label: item.name,
|
||||
color: item.color,
|
||||
},
|
||||
}),
|
||||
{} as ChartConfig,
|
||||
);
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
fill: item.color,
|
||||
color: item.color,
|
||||
percentage: item.percentage,
|
||||
change: item.change,
|
||||
}));
|
||||
|
||||
const legendPayload = chartData.map((entry) => ({
|
||||
value: entry.name,
|
||||
color: entry.color,
|
||||
payload: {
|
||||
percentage: entry.percentage,
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[350px]"
|
||||
>
|
||||
<PieChart>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
strokeWidth={0}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{chartData.map((entry, index) => {
|
||||
const opacity =
|
||||
hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5;
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.fill}
|
||||
opacity={opacity}
|
||||
style={{ transition: "opacity 0.2s" }}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{centerLabel && (
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
const formattedValue =
|
||||
typeof centerLabel.value === "number"
|
||||
? centerLabel.value.toLocaleString()
|
||||
: centerLabel.value;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-white text-3xl font-bold"
|
||||
>
|
||||
{formattedValue}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-slate-400"
|
||||
>
|
||||
{centerLabel.label}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
{showLegend && <CustomLegend payload={legendPayload} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
ui/components/graphs/HorizontalBarChart.tsx
Normal file
121
ui/components/graphs/HorizontalBarChart.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CHART_COLORS, SEVERITY_ORDER } from "./shared/constants";
|
||||
import { getSeverityColorByName } from "./shared/utils";
|
||||
import { BarDataPoint } from "./types";
|
||||
|
||||
interface HorizontalBarChartProps {
|
||||
data: BarDataPoint[];
|
||||
height?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
const orderA = SEVERITY_ORDER[a.name as keyof typeof SEVERITY_ORDER] ?? 999;
|
||||
const orderB = SEVERITY_ORDER[b.name as keyof typeof SEVERITY_ORDER] ?? 999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{sortedData.map((item, index) => {
|
||||
const isHovered = hoveredIndex === index;
|
||||
const isFaded = hoveredIndex !== null && !isHovered;
|
||||
const barColor =
|
||||
item.color ||
|
||||
getSeverityColorByName(item.name) ||
|
||||
CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative flex items-center gap-4"
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<div className="w-24 text-right">
|
||||
<span
|
||||
className="text-sm text-white"
|
||||
style={{
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-0 h-8 w-full rounded-lg bg-slate-700/50" />
|
||||
<div
|
||||
className="relative h-8 rounded-lg transition-all duration-300"
|
||||
style={{
|
||||
width: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
|
||||
backgroundColor: barColor,
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isHovered && (
|
||||
<div className="absolute top-10 left-0 z-10 min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: barColor }}
|
||||
/>
|
||||
<span className="font-semibold text-white">
|
||||
{item.value.toLocaleString()} {item.name} Risk
|
||||
</span>
|
||||
</div>
|
||||
{item.newFindings !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-sm text-slate-400">
|
||||
{item.newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.change !== undefined && (
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<span className="font-bold">
|
||||
{item.change > 0 ? "+" : ""}
|
||||
{item.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex w-40 items-center gap-2 text-sm text-white"
|
||||
style={{
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold">{item.percentage}%</span>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span>{item.value.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
ui/components/graphs/LineChart.tsx
Normal file
189
ui/components/graphs/LineChart.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart as RechartsLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { AlertPill } from "./shared/AlertPill";
|
||||
import { ChartLegend } from "./shared/ChartLegend";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { LineConfig, LineDataPoint } from "./types";
|
||||
|
||||
interface LineChartProps {
|
||||
data: LineDataPoint[];
|
||||
lines: LineConfig[];
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
dataKey: string;
|
||||
value: number;
|
||||
stroke: string;
|
||||
name: string;
|
||||
payload: LineDataPoint;
|
||||
}
|
||||
|
||||
const CustomLineTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: TooltipProps<number, string>) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typedPayload = payload as unknown as TooltipPayloadItem[];
|
||||
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="mb-3 text-xs text-slate-400">{label}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<AlertPill value={totalValue} textSize="sm" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{typedPayload.map((item) => {
|
||||
const newFindings = item.payload[`${item.dataKey}_newFindings`];
|
||||
const change = item.payload[`${item.dataKey}_change`];
|
||||
|
||||
return (
|
||||
<div key={item.dataKey} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.stroke }}
|
||||
/>
|
||||
<span className="text-sm text-white">{item.value}</span>
|
||||
</div>
|
||||
{newFindings !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">
|
||||
{newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{change !== undefined && typeof change === "number" && (
|
||||
<p className="text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{change > 0 ? "+" : ""}
|
||||
{change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const severityOrder = [
|
||||
"Informational",
|
||||
"Low",
|
||||
"Medium",
|
||||
"High",
|
||||
"Critical",
|
||||
"Muted",
|
||||
];
|
||||
|
||||
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) {
|
||||
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsLine
|
||||
data={data}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
146
ui/components/graphs/RadarChart.tsx
Normal file
146
ui/components/graphs/RadarChart.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart as RechartsRadar,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart/Chart";
|
||||
|
||||
import { AlertPill } from "./shared/AlertPill";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { RadarDataPoint } from "./types";
|
||||
|
||||
interface RadarChartProps {
|
||||
data: RadarDataPoint[];
|
||||
height?: number;
|
||||
dataKey?: string;
|
||||
onSelectPoint?: (point: RadarDataPoint | null) => void;
|
||||
selectedPoint?: RadarDataPoint | null;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
value: {
|
||||
label: "Findings",
|
||||
color: "var(--color-magenta)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{data.payload.category}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<AlertPill value={data.value} />
|
||||
</div>
|
||||
{data.payload.change !== undefined && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{data.payload.change > 0 ? "+" : ""}
|
||||
{data.payload.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomDot = (props: any) => {
|
||||
const { cx, cy, payload, selectedPoint, onSelectPoint } = props;
|
||||
const currentCategory = payload.category || payload.name;
|
||||
const isSelected = selectedPoint?.category === currentCategory;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onSelectPoint) {
|
||||
if (isSelected) {
|
||||
onSelectPoint(null);
|
||||
} else {
|
||||
const point = {
|
||||
category: currentCategory,
|
||||
value: payload.value,
|
||||
change: payload.change,
|
||||
};
|
||||
onSelectPoint(point);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={isSelected ? 9 : 6}
|
||||
fill={isSelected ? "var(--color-success)" : "var(--color-purple-dark)"}
|
||||
fillOpacity={1}
|
||||
style={{
|
||||
cursor: onSelectPoint ? "pointer" : "default",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
onClick={onSelectPoint ? handleClick : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function RadarChart({
|
||||
data,
|
||||
height = 400,
|
||||
dataKey = "value",
|
||||
onSelectPoint,
|
||||
selectedPoint,
|
||||
}: RadarChartProps) {
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
<RechartsRadar data={data}>
|
||||
<ChartTooltip cursor={false} content={<CustomTooltip />} />
|
||||
<PolarAngleAxis
|
||||
dataKey="category"
|
||||
tick={{ fill: CHART_COLORS.textPrimary }}
|
||||
/>
|
||||
<PolarGrid strokeOpacity={0.3} />
|
||||
<Radar
|
||||
dataKey={dataKey}
|
||||
fill="var(--color-magenta)"
|
||||
fillOpacity={0.2}
|
||||
activeDot={false}
|
||||
dot={
|
||||
onSelectPoint
|
||||
? (dotProps: any) => {
|
||||
const { key, ...rest } = dotProps;
|
||||
return (
|
||||
<CustomDot
|
||||
key={key}
|
||||
{...rest}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={onSelectPoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: {
|
||||
r: 6,
|
||||
fill: "var(--color-purple-dark)",
|
||||
fillOpacity: 1,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</RechartsRadar>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
78
ui/components/graphs/RadialChart.tsx
Normal file
78
ui/components/graphs/RadialChart.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
interface RadialChartProps {
|
||||
percentage: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
height?: number;
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
}
|
||||
|
||||
export function RadialChart({
|
||||
percentage,
|
||||
label = "Score",
|
||||
color = "var(--color-success)",
|
||||
backgroundColor = CHART_COLORS.tooltipBackground,
|
||||
height = 250,
|
||||
innerRadius = 60,
|
||||
outerRadius = 100,
|
||||
startAngle = 90,
|
||||
endAngle = -270,
|
||||
}: RadialChartProps) {
|
||||
const data = [
|
||||
{
|
||||
name: label,
|
||||
value: percentage,
|
||||
fill: color,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
barSize={20}
|
||||
data={data}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
>
|
||||
<PolarAngleAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
angleAxisId={0}
|
||||
tick={false}
|
||||
/>
|
||||
<RadialBar
|
||||
background={{ fill: backgroundColor }}
|
||||
dataKey="value"
|
||||
cornerRadius={10}
|
||||
fill={color}
|
||||
/>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-white text-4xl font-bold"
|
||||
>
|
||||
{percentage}%
|
||||
</text>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
137
ui/components/graphs/SankeyChart.tsx
Normal file
137
ui/components/graphs/SankeyChart.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
|
||||
|
||||
import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants";
|
||||
|
||||
interface SankeyNode {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SankeyLink {
|
||||
source: number;
|
||||
target: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface SankeyChartProps {
|
||||
data: {
|
||||
nodes: SankeyNode[];
|
||||
links: SankeyLink[];
|
||||
};
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
Success: "var(--color-success)",
|
||||
Fail: "var(--color-destructive)",
|
||||
AWS: "var(--color-orange)",
|
||||
Azure: "var(--color-cyan)",
|
||||
Google: "var(--color-red)",
|
||||
...SEVERITY_COLORS,
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="text-sm font-semibold text-white">{data.name}</p>
|
||||
{data.value && (
|
||||
<p className="text-xs text-slate-400">Value: {data.value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => {
|
||||
const isOut = x + width + 6 > containerWidth;
|
||||
const nodeName = payload.name;
|
||||
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Rectangle
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
fill={color}
|
||||
fillOpacity="1"
|
||||
/>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2}
|
||||
fontSize="14"
|
||||
className="fill-white stroke-white"
|
||||
>
|
||||
{nodeName}
|
||||
</text>
|
||||
<text
|
||||
textAnchor={isOut ? "end" : "start"}
|
||||
x={isOut ? x - 6 : x + width + 6}
|
||||
y={y + height / 2 + 13}
|
||||
fontSize="12"
|
||||
className="fill-slate-400 stroke-slate-400"
|
||||
strokeOpacity="0.5"
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLink = (props: any) => {
|
||||
const {
|
||||
sourceX,
|
||||
targetX,
|
||||
sourceY,
|
||||
targetY,
|
||||
sourceControlX,
|
||||
targetControlX,
|
||||
linkWidth,
|
||||
} = props;
|
||||
|
||||
const sourceName = props.payload.source?.name || "";
|
||||
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${sourceX},${sourceY + linkWidth / 2}
|
||||
C${sourceControlX},${sourceY + linkWidth / 2}
|
||||
${targetControlX},${targetY + linkWidth / 2}
|
||||
${targetX},${targetY + linkWidth / 2}
|
||||
L${targetX},${targetY - linkWidth / 2}
|
||||
C${targetControlX},${targetY - linkWidth / 2}
|
||||
${sourceControlX},${sourceY - linkWidth / 2}
|
||||
${sourceX},${sourceY - linkWidth / 2}
|
||||
Z
|
||||
`}
|
||||
fill={color}
|
||||
fillOpacity="0.4"
|
||||
stroke="none"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Sankey
|
||||
data={data}
|
||||
node={<CustomNode />}
|
||||
link={<CustomLink />}
|
||||
nodePadding={50}
|
||||
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
|
||||
>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</Sankey>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
181
ui/components/graphs/ScatterPlot.tsx
Normal file
181
ui/components/graphs/ScatterPlot.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Scatter,
|
||||
ScatterChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { AlertPill } from "./shared/AlertPill";
|
||||
import { ChartLegend } from "./shared/ChartLegend";
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
import { getSeverityColorByRiskScore } from "./shared/utils";
|
||||
|
||||
interface ScatterDataPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
provider: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface ScatterPlotProps {
|
||||
data: ScatterDataPoint[];
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
height?: number;
|
||||
onSelectPoint?: (point: ScatterDataPoint | null) => void;
|
||||
selectedPoint?: ScatterDataPoint | null;
|
||||
}
|
||||
|
||||
const PROVIDER_COLORS = {
|
||||
AWS: "var(--color-orange)",
|
||||
Azure: "var(--color-cyan)",
|
||||
Google: "var(--color-red)",
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const severityColor = getSeverityColorByRiskScore(data.x);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="text-sm font-semibold text-white">{data.name}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<span style={{ color: severityColor }}>{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,
|
||||
}: any) => {
|
||||
const isSelected = selectedPoint?.name === payload.name;
|
||||
const size = isSelected ? 18 : 8;
|
||||
const fill = isSelected
|
||||
? "var(--color-success)"
|
||||
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
|
||||
CHART_COLORS.defaultColor;
|
||||
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={size / 2}
|
||||
fill={fill}
|
||||
stroke={isSelected ? "var(--color-success)" : "transparent"}
|
||||
strokeWidth={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onSelectPoint?.(payload)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: any) => {
|
||||
const items = payload.map((entry: any) => ({
|
||||
label: entry.value,
|
||||
color: entry.color,
|
||||
}));
|
||||
|
||||
return <ChartLegend items={items} />;
|
||||
};
|
||||
|
||||
export function ScatterPlot({
|
||||
data,
|
||||
xLabel = "Risk Score",
|
||||
yLabel = "Failed Findings",
|
||||
height = 400,
|
||||
onSelectPoint,
|
||||
selectedPoint,
|
||||
}: ScatterPlotProps) {
|
||||
const handlePointClick = (point: ScatterDataPoint) => {
|
||||
if (onSelectPoint) {
|
||||
if (selectedPoint?.name === point.name) {
|
||||
onSelectPoint(null);
|
||||
} else {
|
||||
onSelectPoint(point);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataByProvider = data.reduce(
|
||||
(acc, point) => {
|
||||
const provider = point.provider;
|
||||
if (!acc[provider]) {
|
||||
acc[provider] = [];
|
||||
}
|
||||
acc[provider].push(point);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ScatterDataPoint[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
name={xLabel}
|
||||
label={{
|
||||
value: xLabel,
|
||||
position: "insideBottom",
|
||||
offset: -10,
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}}
|
||||
tick={{ fill: CHART_COLORS.textSecondary }}
|
||||
domain={[0, 10]}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
name={yLabel}
|
||||
label={{
|
||||
value: yLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: CHART_COLORS.textSecondary,
|
||||
}}
|
||||
tick={{ fill: CHART_COLORS.textSecondary }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
{Object.entries(dataByProvider).map(([provider, points]) => (
|
||||
<Scatter
|
||||
key={provider}
|
||||
name={provider}
|
||||
data={points}
|
||||
fill={
|
||||
PROVIDER_COLORS[provider as keyof typeof PROVIDER_COLORS] ||
|
||||
CHART_COLORS.defaultColor
|
||||
}
|
||||
shape={(props: any) => (
|
||||
<CustomScatterDot
|
||||
{...props}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={handlePointClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
33
ui/components/graphs/hooks/useSortableData.ts
Normal file
33
ui/components/graphs/hooks/useSortableData.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { DEFAULT_SORT_OPTION, SORT_OPTIONS } from "../shared/constants";
|
||||
|
||||
type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
|
||||
|
||||
interface SortableItem {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function useSortableData<T extends SortableItem>(data: T[]) {
|
||||
const [sortBy, setSortBy] = useState<SortOption>(DEFAULT_SORT_OPTION);
|
||||
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case SORT_OPTIONS.highLow:
|
||||
return b.value - a.value;
|
||||
case SORT_OPTIONS.lowHigh:
|
||||
return a.value - b.value;
|
||||
case SORT_OPTIONS.alphabetical:
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortedData,
|
||||
};
|
||||
}
|
||||
9
ui/components/graphs/index.ts
Normal file
9
ui/components/graphs/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { BarChart } from "./BarChart";
|
||||
export { DonutChart } from "./DonutChart";
|
||||
export { HorizontalBarChart } from "./HorizontalBarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { RadarChart } from "./RadarChart";
|
||||
export { RadialChart } from "./RadialChart";
|
||||
export { SankeyChart } from "./SankeyChart";
|
||||
export { ScatterPlot } from "./ScatterPlot";
|
||||
export { ChartLegend, type ChartLegendItem } from "./shared/ChartLegend";
|
||||
34
ui/components/graphs/shared/AlertPill.tsx
Normal file
34
ui/components/graphs/shared/AlertPill.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AlertPillProps {
|
||||
value: number;
|
||||
label?: string;
|
||||
iconSize?: number;
|
||||
textSize?: "xs" | "sm" | "base";
|
||||
}
|
||||
|
||||
export function AlertPill({
|
||||
value,
|
||||
label = "Fail Findings",
|
||||
iconSize = 12,
|
||||
textSize = "xs",
|
||||
}: AlertPillProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-alert-pill-bg flex items-center gap-1 rounded-full px-2 py-1">
|
||||
<AlertTriangle size={iconSize} className="text-alert-pill-text" />
|
||||
<span
|
||||
className={cn(
|
||||
`text-${textSize}`,
|
||||
"text-alert-pill-text font-semibold",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn(`text-${textSize}`, "text-slate-400")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
ui/components/graphs/shared/ChartLegend.tsx
Normal file
24
ui/components/graphs/shared/ChartLegend.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface ChartLegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChartLegendProps {
|
||||
items: ChartLegendItem[];
|
||||
}
|
||||
|
||||
export function ChartLegend({ items }: ChartLegendProps) {
|
||||
return (
|
||||
<div className="bg-card-border mt-4 inline-flex gap-[46px] rounded-full border-2 px-[19px] py-[9px]">
|
||||
{items.map((item, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-1">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-xs text-gray-300">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
ui/components/graphs/shared/ChartTooltip.tsx
Normal file
123
ui/components/graphs/shared/ChartTooltip.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Bell, VolumeX } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { TooltipData } from "../types";
|
||||
|
||||
interface ChartTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: any[];
|
||||
label?: string;
|
||||
showColorIndicator?: boolean;
|
||||
colorIndicatorShape?: "circle" | "square";
|
||||
}
|
||||
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
showColorIndicator = true,
|
||||
colorIndicatorShape = "square",
|
||||
}: ChartTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: TooltipData = payload[0].payload || payload[0];
|
||||
const color = payload[0].color || data.color;
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{showColorIndicator && color && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
colorIndicatorShape === "circle" ? "rounded-full" : "rounded-sm",
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-white">{label || data.name}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-white">
|
||||
{typeof data.value === "number"
|
||||
? data.value.toLocaleString()
|
||||
: data.value}
|
||||
{data.percentage !== undefined && ` (${data.percentage}%)`}
|
||||
</p>
|
||||
|
||||
{data.newFindings !== undefined && data.newFindings > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">
|
||||
{data.newFindings} New Findings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.new !== undefined && data.new > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Bell size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">{data.new} New</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.muted !== undefined && data.muted > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<VolumeX size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-400">{data.muted} Muted</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.change !== undefined && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<span className="font-bold">
|
||||
{data.change > 0 ? "+" : ""}
|
||||
{data.change}%
|
||||
</span>{" "}
|
||||
Since Last Scan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip for charts with multiple data series (like LineChart)
|
||||
*/
|
||||
export function MultiSeriesChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: ChartTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
|
||||
<p className="mb-2 text-sm font-semibold text-white">{label}</p>
|
||||
|
||||
{payload.map((entry: any, 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-white">{entry.name}:</span>
|
||||
<span className="text-xs font-semibold text-white">
|
||||
{entry.value}
|
||||
</span>
|
||||
{entry.payload[`${entry.dataKey}_change`] && (
|
||||
<span className="text-xs text-slate-400">
|
||||
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
|
||||
{entry.payload[`${entry.dataKey}_change`]}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
ui/components/graphs/shared/constants.ts
Normal file
46
ui/components/graphs/shared/constants.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const SEVERITY_COLORS = {
|
||||
Informational: "var(--color-info)",
|
||||
Low: "var(--color-warning)",
|
||||
Medium: "var(--color-warning-emphasis)",
|
||||
High: "var(--color-danger)",
|
||||
Critical: "var(--color-danger-emphasis)",
|
||||
} as const;
|
||||
|
||||
export const CHART_COLORS = {
|
||||
tooltipBorder: "var(--color-slate-700)",
|
||||
tooltipBackground: "var(--color-slate-800)",
|
||||
textPrimary: "var(--color-white)",
|
||||
textSecondary: "var(--color-slate-400)",
|
||||
gridLine: "var(--color-slate-700)",
|
||||
backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity
|
||||
alertPillBg: "var(--color-alert-pill-bg)",
|
||||
alertPillText: "var(--color-alert-pill-text)",
|
||||
defaultColor: "var(--color-slate-500)", // Default fallback color for charts
|
||||
} as const;
|
||||
|
||||
export const CHART_DIMENSIONS = {
|
||||
defaultHeight: 400,
|
||||
tooltipMinWidth: "200px",
|
||||
borderRadius: "8px",
|
||||
} as const;
|
||||
|
||||
export const SORT_OPTIONS = {
|
||||
highLow: "high-low",
|
||||
lowHigh: "low-high",
|
||||
alphabetical: "alphabetical",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_SORT_OPTION = SORT_OPTIONS.highLow;
|
||||
|
||||
export const SEVERITY_ORDER = {
|
||||
Critical: 0,
|
||||
High: 1,
|
||||
Medium: 2,
|
||||
Low: 3,
|
||||
Informational: 4,
|
||||
} as const;
|
||||
|
||||
export const LAYOUT_OPTIONS = {
|
||||
horizontal: "horizontal",
|
||||
vertical: "vertical",
|
||||
} as const;
|
||||
13
ui/components/graphs/shared/utils.ts
Normal file
13
ui/components/graphs/shared/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SEVERITY_COLORS } from "./constants";
|
||||
|
||||
export function getSeverityColorByRiskScore(riskScore: number): string {
|
||||
if (riskScore >= 7) return SEVERITY_COLORS.Critical;
|
||||
if (riskScore >= 5) return SEVERITY_COLORS.High;
|
||||
if (riskScore >= 3) return SEVERITY_COLORS.Medium;
|
||||
if (riskScore >= 1) return SEVERITY_COLORS.Low;
|
||||
return SEVERITY_COLORS.Informational;
|
||||
}
|
||||
|
||||
export function getSeverityColorByName(name: string): string | undefined {
|
||||
return SEVERITY_COLORS[name as keyof typeof SEVERITY_COLORS];
|
||||
}
|
||||
55
ui/components/graphs/types.ts
Normal file
55
ui/components/graphs/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { LAYOUT_OPTIONS, SORT_OPTIONS } from "./shared/constants";
|
||||
|
||||
export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
|
||||
|
||||
export type LayoutOption = (typeof LAYOUT_OPTIONS)[keyof typeof LAYOUT_OPTIONS];
|
||||
|
||||
export interface BaseDataPoint {
|
||||
name: string;
|
||||
value: number;
|
||||
percentage?: number;
|
||||
color?: string;
|
||||
change?: number;
|
||||
newFindings?: number;
|
||||
}
|
||||
|
||||
export interface BarDataPoint extends BaseDataPoint {}
|
||||
|
||||
export interface DonutDataPoint {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
percentage?: number;
|
||||
new?: number;
|
||||
muted?: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
export interface LineDataPoint {
|
||||
date: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface RadarDataPoint {
|
||||
category: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
export interface LineConfig {
|
||||
dataKey: string;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TooltipData {
|
||||
name: string;
|
||||
value: number | string;
|
||||
color?: string;
|
||||
percentage?: number;
|
||||
newFindings?: number;
|
||||
new?: number;
|
||||
muted?: number;
|
||||
change?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
Fira_Code as FontMono,
|
||||
Plus_Jakarta_Sans as FontSans,
|
||||
} from "next/font/google";
|
||||
import { Fira_Code as FontMono, Work_Sans as FontSans } from "next/font/google";
|
||||
|
||||
export const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
preload: false,
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const fontMono = FontMono({
|
||||
|
||||
@@ -1,5 +1,240 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@theme {
|
||||
/* Font Families are injected by Next.js from config/fonts.ts */
|
||||
/* --font-sans: Work Sans (from Next.js) */
|
||||
/* --font-mono: Fira Code (from Next.js) */
|
||||
|
||||
/* Text Sizes with Line Heights - Exact from Figma Design System */
|
||||
--text-xs: 0.625rem; /* 10px */
|
||||
--text-xs--line-height: 1.6;
|
||||
--text-sm: 0.75rem; /* 12px */
|
||||
--text-sm--line-height: 1.667;
|
||||
--text-base: 0.875rem; /* 14px */
|
||||
--text-base--line-height: 1.714;
|
||||
--text-lg: 1rem; /* 16px */
|
||||
--text-lg--line-height: 1.75;
|
||||
--text-xl: 1.125rem; /* 18px */
|
||||
--text-xl--line-height: 1.556;
|
||||
--text-2xl: 1.25rem; /* 20px */
|
||||
--text-2xl--line-height: 1.6;
|
||||
--text-3xl: 1.5rem; /* 24px */
|
||||
--text-3xl--line-height: 1.5;
|
||||
--text-4xl: 1.875rem; /* 30px */
|
||||
--text-4xl--line-height: 1.333;
|
||||
--text-5xl: 2.25rem; /* 36px */
|
||||
--text-5xl--line-height: 1.333;
|
||||
--text-6xl: 3rem; /* 48px */
|
||||
--text-6xl--line-height: 1.25;
|
||||
--text-7xl: 3.75rem; /* 60px */
|
||||
--text-7xl--line-height: 1.2;
|
||||
--text-8xl: 4.5rem; /* 72px */
|
||||
--text-8xl--line-height: 1.333;
|
||||
--text-9xl: 6rem; /* 96px */
|
||||
--text-9xl--line-height: 1.333;
|
||||
|
||||
/* Font Weights - Complete Inter Scale */
|
||||
--font-weight-thin: 100;
|
||||
--font-weight-extralight: 200;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--font-weight-extrabold: 800;
|
||||
--font-weight-black: 900;
|
||||
|
||||
/* Info & Primary Colors */
|
||||
--color-info: #2e51b2;
|
||||
--color-primary: #2e51b2;
|
||||
|
||||
/* Warning Colors */
|
||||
--color-warning: #fdd34f;
|
||||
--color-warning-emphasis: #ff7d19;
|
||||
|
||||
/* Danger & Destructive Colors */
|
||||
--color-danger: #ff3077;
|
||||
--color-danger-emphasis: #971348;
|
||||
--color-destructive: #db2b49;
|
||||
|
||||
/* Success Colors */
|
||||
--color-success: #86da26;
|
||||
--color-success-emphasis: #20b853;
|
||||
|
||||
/* Provider-specific Colors */
|
||||
--color-orange: #ff9800;
|
||||
--color-cyan: #06b6d4;
|
||||
--color-red: #ef4444;
|
||||
|
||||
/* Accent Colors */
|
||||
--color-magenta: #b51c80;
|
||||
--color-purple-dark: #5f1551;
|
||||
|
||||
/* Chart Card Colors - From Figma */
|
||||
--color-card-bg: #080f23;
|
||||
--color-card-border: #171d30;
|
||||
--color-text-accent: #fface2;
|
||||
--color-page-bg: #1a2034;
|
||||
|
||||
/* Alert Pill Colors */
|
||||
--color-alert-pill-bg: #432232;
|
||||
--color-alert-pill-text: #f54280;
|
||||
|
||||
/* ===========================
|
||||
Border Radius
|
||||
=========================== */
|
||||
|
||||
/* Widget Border Radius - From Figma */
|
||||
--radius-widget: 12px;
|
||||
|
||||
/* White & Black */
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
/* Slate Scale */
|
||||
--color-slate-50: #f8fafc;
|
||||
--color-slate-100: #f1f5f9;
|
||||
--color-slate-200: #e2e8f0;
|
||||
--color-slate-300: #cbd5e1;
|
||||
--color-slate-400: #94a3b8;
|
||||
--color-slate-500: #64748b;
|
||||
--color-slate-600: #475569;
|
||||
--color-slate-700: #334155;
|
||||
--color-slate-800: #1e293b;
|
||||
--color-slate-900: #0f172a;
|
||||
--color-slate-950: #020617;
|
||||
|
||||
/* Gray Scale */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d1d5db;
|
||||
--color-gray-400: #9ca3af;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #4b5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1f2937;
|
||||
--color-gray-900: #111827;
|
||||
--color-gray-950: #030712;
|
||||
|
||||
/* Zinc Scale */
|
||||
--color-zinc-50: #fafafa;
|
||||
--color-zinc-100: #f4f4f5;
|
||||
--color-zinc-200: #e4e4e7;
|
||||
--color-zinc-300: #d4d4d8;
|
||||
--color-zinc-400: #a1a1aa;
|
||||
--color-zinc-500: #71717a;
|
||||
--color-zinc-600: #52525b;
|
||||
--color-zinc-700: #3f3f46;
|
||||
--color-zinc-800: #27272a;
|
||||
--color-zinc-900: #18181b;
|
||||
--color-zinc-950: #09090b;
|
||||
|
||||
/* Stone Scale */
|
||||
--color-stone-50: #fafaf9;
|
||||
--color-stone-100: #f5f5f4;
|
||||
--color-stone-200: #e7e5e4;
|
||||
--color-stone-300: #d6d3d1;
|
||||
--color-stone-400: #a8a29e;
|
||||
--color-stone-500: #78716c;
|
||||
--color-stone-600: #57534e;
|
||||
--color-stone-700: #44403c;
|
||||
--color-stone-800: #292524;
|
||||
--color-stone-900: #1c1917;
|
||||
--color-stone-950: #0c0a09;
|
||||
|
||||
/* Red Scale */
|
||||
--color-red-50: #fef2f2;
|
||||
--color-red-100: #fee2e2;
|
||||
--color-red-200: #fecaca;
|
||||
--color-red-300: #fca5a5;
|
||||
--color-red-400: #f87171;
|
||||
--color-red-500: #ef4444;
|
||||
--color-red-600: #dc2626;
|
||||
--color-red-700: #b91c1c;
|
||||
--color-red-800: #991b1b;
|
||||
--color-red-900: #7f1d1d;
|
||||
--color-red-950: #450a0a;
|
||||
|
||||
/* Rose Scale */
|
||||
--color-rose-50: #fff1f2;
|
||||
--color-rose-100: #ffe4e6;
|
||||
--color-rose-200: #fecdd3;
|
||||
--color-rose-300: #fda4af;
|
||||
--color-rose-400: #fb7185;
|
||||
--color-rose-500: #f43f5e;
|
||||
--color-rose-600: #e11d48;
|
||||
--color-rose-700: #be123c;
|
||||
--color-rose-800: #9f1239;
|
||||
--color-rose-900: #881337;
|
||||
--color-rose-950: #4c0519;
|
||||
|
||||
/* Pink Scale */
|
||||
--color-pink-50: #fdf2f8;
|
||||
--color-pink-100: #fce7f3;
|
||||
--color-pink-200: #fbcfe8;
|
||||
--color-pink-300: #f9a8d4;
|
||||
--color-pink-400: #f472b6;
|
||||
--color-pink-500: #ec4899;
|
||||
--color-pink-600: #db2777;
|
||||
--color-pink-700: #be185d;
|
||||
--color-pink-800: #9d174d;
|
||||
--color-pink-900: #831843;
|
||||
--color-pink-950: #500724;
|
||||
|
||||
/* Fuchsia Scale */
|
||||
--color-fuchsia-50: #fdf4ff;
|
||||
--color-fuchsia-100: #fae8ff;
|
||||
--color-fuchsia-200: #f5d0fe;
|
||||
--color-fuchsia-300: #f0abfc;
|
||||
--color-fuchsia-400: #e879f9;
|
||||
--color-fuchsia-500: #d946ef;
|
||||
--color-fuchsia-600: #c026d3;
|
||||
--color-fuchsia-700: #a21caf;
|
||||
--color-fuchsia-800: #86198f;
|
||||
--color-fuchsia-900: #701a75;
|
||||
--color-fuchsia-950: #4a044e;
|
||||
|
||||
/* Purple Scale */
|
||||
--color-purple-50: #faf5ff;
|
||||
--color-purple-100: #f3e8ff;
|
||||
--color-purple-200: #e9d5ff;
|
||||
--color-purple-300: #d8b4fe;
|
||||
--color-purple-400: #c084fc;
|
||||
--color-purple-500: #a855f7;
|
||||
--color-purple-600: #9333ea;
|
||||
--color-purple-700: #7e22ce;
|
||||
--color-purple-800: #6b21a8;
|
||||
--color-purple-900: #581c87;
|
||||
--color-purple-950: #3b0764;
|
||||
|
||||
/* Violet Scale */
|
||||
--color-violet-50: #f5f3ff;
|
||||
--color-violet-100: #ede9fe;
|
||||
--color-violet-200: #ddd6fe;
|
||||
--color-violet-300: #c4b5fd;
|
||||
--color-violet-400: #a78bfa;
|
||||
--color-violet-500: #8b5cf6;
|
||||
--color-violet-600: #7c3aed;
|
||||
--color-violet-700: #6d28d9;
|
||||
--color-violet-800: #5b21b6;
|
||||
--color-violet-900: #4c1d95;
|
||||
--color-violet-950: #2e1065;
|
||||
|
||||
/* Indigo Scale */
|
||||
--color-indigo-50: #eef2ff;
|
||||
--color-indigo-100: #e0e7ff;
|
||||
--color-indigo-200: #c7d2fe;
|
||||
--color-indigo-300: #a5b4fc;
|
||||
--color-indigo-400: #818cf8;
|
||||
--color-indigo-500: #6366f1;
|
||||
--color-indigo-600: #4f46e5;
|
||||
--color-indigo-700: #4338ca;
|
||||
--color-indigo-800: #3730a3;
|
||||
--color-indigo-900: #312e81;
|
||||
--color-indigo-950: #1e1b4b;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@@ -44,8 +279,8 @@
|
||||
}
|
||||
|
||||
.checkbox-update {
|
||||
margin-right: 0.5rem;
|
||||
background-color: var(--background);
|
||||
margin-right: 0.5rem;
|
||||
background-color: var(--background);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,4 +299,4 @@
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user