mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
274 lines
7.7 KiB
TypeScript
274 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { Bell } from "lucide-react";
|
|
import { useState } from "react";
|
|
import {
|
|
CartesianGrid,
|
|
Line,
|
|
LineChart as RechartsLine,
|
|
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 { 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 {
|
|
dataKey: string;
|
|
value: number;
|
|
stroke: string;
|
|
name: string;
|
|
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,
|
|
filterLine,
|
|
}: CustomLineTooltipProps) => {
|
|
if (!active || !payload || payload.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const typedPayload = payload as unknown as TooltipPayloadItem[];
|
|
|
|
// 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">
|
|
{formattedDate}
|
|
</p>
|
|
|
|
<div className="mb-3">
|
|
<AlertPill value={totalValue} textSize="sm" />
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{displayPayload.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-text-neutral-primary text-sm">
|
|
{item.value}
|
|
</span>
|
|
</div>
|
|
{newFindings !== undefined && (
|
|
<div className="flex items-center gap-2">
|
|
<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-text-neutral-secondary text-xs">
|
|
<span className="font-bold">
|
|
{(change as number) > 0 ? "+" : ""}
|
|
{change}%
|
|
</span>{" "}
|
|
Since Last Scan
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const chartConfig = {
|
|
default: {
|
|
color: "var(--color-bg-data-azure)",
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
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
|
|
config={chartConfig}
|
|
className="w-full overflow-hidden"
|
|
style={{ height, aspectRatio: "auto" }}
|
|
>
|
|
<RechartsLine
|
|
data={data}
|
|
margin={{
|
|
top: 10,
|
|
left: 0,
|
|
right: 30,
|
|
bottom: 40,
|
|
}}
|
|
style={{ cursor: onPointClick ? "pointer" : "default" }}
|
|
>
|
|
<CartesianGrid
|
|
vertical={false}
|
|
strokeOpacity={1}
|
|
stroke="var(--border-neutral-secondary)"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
interval={xAxisInterval}
|
|
tick={(props) => (
|
|
<CustomXAxisTickWithToday {...props} data={data} />
|
|
)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
tick={{
|
|
fill: "var(--color-text-neutral-secondary)",
|
|
fontSize: AXIS_FONT_SIZE,
|
|
}}
|
|
/>
|
|
<ChartTooltip
|
|
cursor={{
|
|
stroke: "var(--color-text-neutral-tertiary)",
|
|
strokeWidth: 1,
|
|
strokeDasharray: "4 4",
|
|
}}
|
|
content={<CustomLineTooltip filterLine={activeLine} />}
|
|
/>
|
|
{lines.map((line) => {
|
|
const isActive = activeLine === line.dataKey;
|
|
const isFaded = activeLine !== null && !isActive;
|
|
return (
|
|
<Line
|
|
key={line.dataKey}
|
|
type="natural"
|
|
dataKey={line.dataKey}
|
|
stroke={line.color}
|
|
strokeWidth={2}
|
|
strokeOpacity={isFaded ? 0.2 : 1}
|
|
name={line.label}
|
|
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" }}
|
|
/>
|
|
);
|
|
})}
|
|
</RechartsLine>
|
|
</ChartContainer>
|
|
|
|
<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>
|
|
);
|
|
}
|