mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(ui): add Finding Severity Over Time chart to overview page (#9405)
This commit is contained in:
@@ -19,16 +19,20 @@ import {
|
||||
|
||||
import { AlertPill } from "./shared/alert-pill";
|
||||
import { ChartLegend } from "./shared/chart-legend";
|
||||
import { CustomActiveDot, PointClickData } from "./shared/custom-active-dot";
|
||||
import {
|
||||
AXIS_FONT_SIZE,
|
||||
CustomXAxisTickWithToday,
|
||||
} from "./shared/custom-axis-tick";
|
||||
import { CustomDot } from "./shared/custom-dot";
|
||||
import { LineConfig, LineDataPoint } from "./types";
|
||||
|
||||
interface LineChartProps {
|
||||
data: LineDataPoint[];
|
||||
lines: LineConfig[];
|
||||
height?: number;
|
||||
xAxisInterval?: number | "preserveStart" | "preserveEnd" | "preserveStartEnd";
|
||||
onPointClick?: (data: PointClickData) => void;
|
||||
}
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
@@ -39,28 +43,54 @@ interface TooltipPayloadItem {
|
||||
payload: LineDataPoint;
|
||||
}
|
||||
|
||||
const formatTooltipDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
interface CustomLineTooltipProps extends TooltipProps<number, string> {
|
||||
filterLine?: string | null;
|
||||
}
|
||||
|
||||
const CustomLineTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: TooltipProps<number, string>) => {
|
||||
filterLine,
|
||||
}: CustomLineTooltipProps) => {
|
||||
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);
|
||||
|
||||
// Filter payload if a line is selected or hovered
|
||||
const displayPayload = filterLine
|
||||
? typedPayload.filter((item) => item.dataKey === filterLine)
|
||||
: typedPayload;
|
||||
|
||||
if (displayPayload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalValue = displayPayload.reduce((sum, item) => sum + item.value, 0);
|
||||
const formattedDate = formatTooltipDate(String(label));
|
||||
|
||||
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-secondary mb-3 text-xs">{label}</p>
|
||||
<p className="text-text-neutral-secondary mb-3 text-xs">
|
||||
{formattedDate}
|
||||
</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<AlertPill value={totalValue} textSize="sm" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{typedPayload.map((item) => {
|
||||
{displayPayload.map((item) => {
|
||||
const newFindings = item.payload[`${item.dataKey}_newFindings`];
|
||||
const change = item.payload[`${item.dataKey}_change`];
|
||||
|
||||
@@ -106,14 +136,30 @@ const chartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
||||
export function LineChart({
|
||||
data,
|
||||
lines,
|
||||
height = 400,
|
||||
xAxisInterval = "preserveStartEnd",
|
||||
onPointClick,
|
||||
}: LineChartProps) {
|
||||
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
|
||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||
|
||||
// Active line is either selected (persistent) or hovered (temporary)
|
||||
const activeLine = selectedLine ?? hoveredLine;
|
||||
|
||||
const legendItems = lines.map((line) => ({
|
||||
label: line.label,
|
||||
color: line.color,
|
||||
dataKey: line.dataKey,
|
||||
}));
|
||||
|
||||
const handleLegendClick = (dataKey: string) => {
|
||||
// Toggle selection: if already selected, deselect; otherwise select
|
||||
setSelectedLine((current) => (current === dataKey ? null : dataKey));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ChartContainer
|
||||
@@ -126,9 +172,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
||||
margin={{
|
||||
top: 10,
|
||||
left: 0,
|
||||
right: 8,
|
||||
bottom: 20,
|
||||
right: 30,
|
||||
bottom: 40,
|
||||
}}
|
||||
style={{ cursor: onPointClick ? "pointer" : "default" }}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
@@ -140,7 +187,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tick={CustomXAxisTickWithToday}
|
||||
interval={xAxisInterval}
|
||||
tick={(props) => (
|
||||
<CustomXAxisTickWithToday {...props} data={data} />
|
||||
)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
@@ -151,10 +201,17 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
||||
fontSize: AXIS_FONT_SIZE,
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<CustomLineTooltip />} />
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: "var(--color-text-neutral-tertiary)",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
content={<CustomLineTooltip filterLine={activeLine} />}
|
||||
/>
|
||||
{lines.map((line) => {
|
||||
const isHovered = hoveredLine === line.dataKey;
|
||||
const isFaded = hoveredLine !== null && !isHovered;
|
||||
const isActive = activeLine === line.dataKey;
|
||||
const isFaded = activeLine !== null && !isActive;
|
||||
return (
|
||||
<Line
|
||||
key={line.dataKey}
|
||||
@@ -162,12 +219,38 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
||||
dataKey={line.dataKey}
|
||||
stroke={line.color}
|
||||
strokeWidth={2}
|
||||
strokeOpacity={isFaded ? 0.5 : 1}
|
||||
strokeOpacity={isFaded ? 0.2 : 1}
|
||||
name={line.label}
|
||||
dot={{ fill: line.color, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
onMouseEnter={() => setHoveredLine(line.dataKey)}
|
||||
onMouseLeave={() => setHoveredLine(null)}
|
||||
dot={({
|
||||
key,
|
||||
...props
|
||||
}: {
|
||||
key?: string;
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
}) => (
|
||||
<CustomDot
|
||||
key={key}
|
||||
{...props}
|
||||
color={line.color}
|
||||
isFaded={isFaded}
|
||||
/>
|
||||
)}
|
||||
activeDot={(props: {
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
payload?: LineDataPoint;
|
||||
}) => (
|
||||
<CustomActiveDot
|
||||
{...props}
|
||||
dataKey={line.dataKey}
|
||||
color={line.color}
|
||||
isFaded={isFaded}
|
||||
onPointClick={onPointClick}
|
||||
onMouseEnter={() => setHoveredLine(line.dataKey)}
|
||||
onMouseLeave={() => setHoveredLine(null)}
|
||||
/>
|
||||
)}
|
||||
style={{ transition: "stroke-opacity 0.2s" }}
|
||||
/>
|
||||
);
|
||||
@@ -175,8 +258,15 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
||||
</RechartsLine>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="mt-4">
|
||||
<ChartLegend items={legendItems} />
|
||||
<div className="mt-4 flex flex-col items-start gap-2">
|
||||
<p className="text-text-neutral-tertiary pl-2 text-xs">
|
||||
Click to filter by severity.
|
||||
</p>
|
||||
<ChartLegend
|
||||
items={legendItems}
|
||||
selectedItem={selectedLine}
|
||||
onItemClick={handleLegendClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
export interface ChartLegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
dataKey?: string;
|
||||
}
|
||||
|
||||
interface ChartLegendProps {
|
||||
items: ChartLegendItem[];
|
||||
selectedItem?: string | null;
|
||||
onItemClick?: (dataKey: string) => void;
|
||||
}
|
||||
|
||||
export function ChartLegend({ items }: ChartLegendProps) {
|
||||
export function ChartLegend({
|
||||
items,
|
||||
selectedItem,
|
||||
onItemClick,
|
||||
}: ChartLegendProps) {
|
||||
const isInteractive = !!onItemClick;
|
||||
|
||||
return (
|
||||
<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-2 px-4 py-3"
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
const dataKey = item.dataKey ?? item.label.toLowerCase();
|
||||
const isSelected = selectedItem === dataKey;
|
||||
const isFaded = selectedItem !== null && !isSelected;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`legend-${index}`}
|
||||
type="button"
|
||||
className={`flex items-center gap-2 px-4 py-3 transition-opacity duration-200 ${
|
||||
isInteractive
|
||||
? "cursor-pointer hover:opacity-80"
|
||||
: "cursor-default"
|
||||
} ${isFaded ? "opacity-30" : "opacity-100"}`}
|
||||
onClick={() => onItemClick?.(dataKey)}
|
||||
disabled={!isInteractive}
|
||||
>
|
||||
<div
|
||||
className={`h-3 w-3 rounded ${isSelected ? "ring-2 ring-offset-1" : ""}`}
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
// @ts-expect-error ring-color is a valid Tailwind CSS variable
|
||||
"--tw-ring-color": item.color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Dot } from "recharts";
|
||||
|
||||
import { LineDataPoint } from "../types";
|
||||
|
||||
export interface PointClickData {
|
||||
point: LineDataPoint;
|
||||
dataKey?: string;
|
||||
}
|
||||
|
||||
interface CustomActiveDotProps {
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
payload?: LineDataPoint;
|
||||
dataKey: string;
|
||||
color: string;
|
||||
isFaded: boolean;
|
||||
onPointClick?: (data: PointClickData) => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
export const CustomActiveDot = ({
|
||||
cx,
|
||||
cy,
|
||||
payload,
|
||||
dataKey,
|
||||
color,
|
||||
isFaded,
|
||||
onPointClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: CustomActiveDotProps) => {
|
||||
if (cx === undefined || cy === undefined) return null;
|
||||
|
||||
// Don't render active dot for faded lines
|
||||
if (isFaded) return null;
|
||||
|
||||
return (
|
||||
<Dot
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={6}
|
||||
fill={color}
|
||||
style={{ cursor: onPointClick ? "pointer" : "default" }}
|
||||
onClick={() => {
|
||||
if (onPointClick && payload) {
|
||||
onPointClick({ point: payload, dataKey });
|
||||
}
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,55 @@
|
||||
export const AXIS_FONT_SIZE = 14;
|
||||
const TODAY_FONT_SIZE = 12;
|
||||
const MONTH_FONT_SIZE = 11;
|
||||
|
||||
interface CustomXAxisTickProps {
|
||||
x: number;
|
||||
y: number;
|
||||
index?: number;
|
||||
payload: {
|
||||
value: string | number;
|
||||
};
|
||||
visibleTicksCount?: number;
|
||||
}
|
||||
|
||||
const getTodayFormatted = () => {
|
||||
const getTodayISO = () => {
|
||||
const today = new Date();
|
||||
return today.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
return today.toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const getMonthName = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("en-US", { month: "short" });
|
||||
};
|
||||
|
||||
const getDayNumber = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.getDate();
|
||||
};
|
||||
|
||||
const getMonthFromDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.getMonth();
|
||||
};
|
||||
|
||||
export const CustomXAxisTickWithToday = Object.assign(
|
||||
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
|
||||
const { x, y, payload } = props;
|
||||
const todayFormatted = getTodayFormatted();
|
||||
const isToday = String(payload.value) === todayFormatted;
|
||||
function CustomXAxisTickWithToday(
|
||||
props: CustomXAxisTickProps & { data?: Array<{ date: string }> },
|
||||
) {
|
||||
const { x, y, payload, index = 0, data = [] } = props;
|
||||
const dateStr = String(payload.value);
|
||||
const todayISO = getTodayISO();
|
||||
const isToday = dateStr === todayISO;
|
||||
|
||||
const dayNumber = getDayNumber(dateStr);
|
||||
const currentMonth = getMonthFromDate(dateStr);
|
||||
|
||||
// Show month name if it's the first tick or if the month changed from previous tick
|
||||
const isFirstTick = index === 0;
|
||||
const previousDate = index > 0 && data[index - 1]?.date;
|
||||
const previousMonth = previousDate ? getMonthFromDate(previousDate) : -1;
|
||||
const monthChanged = currentMonth !== previousMonth;
|
||||
const showMonth = isFirstTick || monthChanged;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
@@ -33,12 +61,23 @@ export const CustomXAxisTickWithToday = Object.assign(
|
||||
fill="var(--color-text-neutral-secondary)"
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
>
|
||||
{payload.value}
|
||||
{dayNumber}
|
||||
</text>
|
||||
{showMonth && (
|
||||
<text
|
||||
x={0}
|
||||
y={42}
|
||||
textAnchor="middle"
|
||||
fill="var(--color-text-neutral-tertiary)"
|
||||
fontSize={MONTH_FONT_SIZE}
|
||||
>
|
||||
{getMonthName(dateStr)}
|
||||
</text>
|
||||
)}
|
||||
{isToday && (
|
||||
<text
|
||||
x={0}
|
||||
y={36}
|
||||
y={showMonth ? 56 : 42}
|
||||
textAnchor="middle"
|
||||
fill="var(--color-text-neutral-secondary)"
|
||||
fontSize={TODAY_FONT_SIZE}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Dot } from "recharts";
|
||||
|
||||
interface CustomDotProps {
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
color: string;
|
||||
isFaded: boolean;
|
||||
}
|
||||
|
||||
export const CustomDot = ({ cx, cy, color, isFaded }: CustomDotProps) => {
|
||||
if (cx === undefined || cy === undefined) return null;
|
||||
|
||||
return <Dot cx={cx} cy={cy} r={4} fill={color} opacity={isFaded ? 0.2 : 1} />;
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export interface DonutDataPoint {
|
||||
|
||||
export interface LineDataPoint {
|
||||
date: string;
|
||||
[key: string]: string | number;
|
||||
[key: string]: string | number | string[];
|
||||
}
|
||||
|
||||
export interface RadarDataPoint {
|
||||
@@ -60,5 +60,5 @@ export interface TooltipData {
|
||||
new?: number;
|
||||
muted?: number;
|
||||
change?: number;
|
||||
[key: string]: any;
|
||||
[key: string]: string | number | boolean | string[] | undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user