import { useState } from "react"; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DEFAULT_CHART_RANGES, type ChartRangeOption, type ChartRangeValue, } from "./chartRanges"; import { formatScaled, metricScaleInfo, type MetricScale, type MetricUnit, } from "../lib/metricFormat"; export interface SeriesPoint { t: number; v: number | null; } export interface ChartSeries { label: string; points: SeriesPoint[]; } /** Merge multiple time-series into a single recharts-friendly array. */ function mergeSeries(series: ChartSeries[]): Record[] { const map = new Map>(); for (const seriesItem of series) { for (const point of seriesItem.points) { const existing = map.get(point.t) ?? { time: point.t }; existing[seriesItem.label] = point.v; map.set(point.t, existing); } } return [...map.values()].sort( (a, b) => (a.time as number) - (b.time as number), ); } function formatTime(ms: number): string { return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); } const CHART_COLORS = [ "var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)", ]; interface LineSeriesChartProps { series: ChartSeries[]; height?: number; /** Display unit for the Y axis + tooltip (drives decimal-prefix scaling). */ unit?: MetricUnit; /** "auto" picks a prefix from the data magnitude; k/m/g/t force one. */ scale?: MetricScale; /** Available displayed time ranges. Defaults to the shared range choices. */ rangeOptions?: readonly ChartRangeOption[]; /** Whether to render the interactive range selector. */ showRangeSelector?: boolean; /** Initial uncontrolled range. Defaults to the largest numeric option. */ defaultRangeSeconds?: ChartRangeValue; /** Controlled range for consumers that refetch when the selection changes. */ rangeSeconds?: ChartRangeValue; onRangeChange?: (range: ChartRangeValue) => void; } /** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */ export function LineSeriesChart({ series, height = 300, unit = "none", scale = "auto", rangeOptions = DEFAULT_CHART_RANGES, showRangeSelector = true, defaultRangeSeconds, rangeSeconds, onRangeChange, }: LineSeriesChartProps) { const initialRange = defaultRangeSeconds ?? [...rangeOptions].reverse().find((range) => typeof range.value === "number") ?.value; const [localRangeSeconds, setLocalRangeSeconds] = useState< ChartRangeValue | undefined >(initialRange); const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds; const latestTimestamp = series.reduce( (max, seriesItem) => seriesItem.points.reduce( (seriesMax, point) => Math.max(seriesMax, point.t), max, ), 0, ); const cutoff = typeof selectedRangeSeconds === "number" ? latestTimestamp - selectedRangeSeconds * 1000 : null; const visibleSeries = cutoff !== null && latestTimestamp > 0 ? series.map((seriesItem) => ({ ...seriesItem, points: seriesItem.points.filter((point) => point.t >= cutoff), })) : series; const maxAbs = visibleSeries.reduce((max, seriesItem) => { for (const point of seriesItem.points) { const value = point.v == null ? 0 : Math.abs(point.v); if (value > max) max = value; } return max; }, 0); const scaleInfo = metricScaleInfo(maxAbs, unit, scale); const formatValue = (value: number | null | undefined) => formatScaled(value, scaleInfo, unit); function handleRangeChange(value: string) { const nextRange: ChartRangeValue = value === "all" ? "all" : Number(value); setLocalRangeSeconds(nextRange); onRangeChange?.(nextRange); } return (
{showRangeSelector && rangeOptions.length > 0 && (
)} formatTime(Number(label))} formatter={(value) => formatValue(Number(value))} contentStyle={{ backgroundColor: "var(--color-popover)", border: "1px solid var(--color-border)", borderRadius: "0.5rem", color: "var(--color-popover-foreground)", }} /> {visibleSeries.map((seriesItem, index) => ( ))}
); }