Files
manage/frontend/src/components/LineSeriesChart.tsx
T
2026-07-15 19:15:06 +00:00

215 lines
5.6 KiB
TypeScript

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<string, unknown>[] {
const map = new Map<number, Record<string, unknown>>();
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 (
<div className="space-y-2">
{showRangeSelector && rangeOptions.length > 0 && (
<div className="flex justify-end">
<Select
value={
selectedRangeSeconds === undefined
? undefined
: String(selectedRangeSeconds)
}
onValueChange={handleRangeChange}
>
<SelectTrigger
className="w-[150px]"
size="sm"
aria-label="Chart range"
>
<SelectValue placeholder="Chart range" />
</SelectTrigger>
<SelectContent>
{rangeOptions.map((range) => (
<SelectItem key={range.value} value={String(range.value)}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<ResponsiveContainer width="100%" height={height}>
<LineChart data={mergeSeries(visibleSeries)}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis
tickFormatter={formatValue}
tick={{ fontSize: 11 }}
width={56}
className="fill-muted-foreground"
/>
<Tooltip
labelFormatter={(label) => 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) => (
<Line
key={seriesItem.label}
type="monotone"
dataKey={seriesItem.label}
stroke={CHART_COLORS[index % CHART_COLORS.length]}
dot={false}
strokeWidth={2}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}