215 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|