feat(charting): unify configurable time windows

This commit is contained in:
Developer
2026-07-15 18:55:57 +00:00
parent 3871f24724
commit e0f66a51f7
25 changed files with 1607 additions and 1146 deletions
+22 -12
View File
@@ -15,7 +15,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DEFAULT_CHART_RANGES, type ChartRangeOption } from "./chartRanges";
import {
DEFAULT_CHART_RANGES,
type ChartRangeOption,
type ChartRangeValue,
} from "./chartRanges";
import {
formatScaled,
metricScaleInfo,
@@ -72,11 +76,11 @@ interface LineSeriesChartProps {
scale?: MetricScale;
/** Available displayed time ranges. Defaults to the shared range choices. */
rangeOptions?: readonly ChartRangeOption[];
/** Initial uncontrolled range. Defaults to the largest available option. */
defaultRangeSeconds?: number;
/** Initial uncontrolled range. Defaults to the largest numeric option. */
defaultRangeSeconds?: ChartRangeValue;
/** Controlled range for consumers that refetch when the selection changes. */
rangeSeconds?: number;
onRangeChange?: (rangeSeconds: number) => void;
rangeSeconds?: ChartRangeValue;
onRangeChange?: (range: ChartRangeValue) => void;
}
/** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
@@ -91,8 +95,11 @@ export function LineSeriesChart({
onRangeChange,
}: LineSeriesChartProps) {
const initialRange =
defaultRangeSeconds ?? rangeOptions[rangeOptions.length - 1]?.value;
const [localRangeSeconds, setLocalRangeSeconds] = useState(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) =>
@@ -102,9 +109,10 @@ export function LineSeriesChart({
),
0,
);
const cutoff = selectedRangeSeconds
? latestTimestamp - selectedRangeSeconds * 1000
: null;
const cutoff =
typeof selectedRangeSeconds === "number"
? latestTimestamp - selectedRangeSeconds * 1000
: null;
const visibleSeries =
cutoff !== null && latestTimestamp > 0
? series.map((seriesItem) => ({
@@ -125,7 +133,7 @@ export function LineSeriesChart({
formatScaled(value, scaleInfo, unit);
function handleRangeChange(value: string) {
const nextRange = Number(value);
const nextRange: ChartRangeValue = value === "all" ? "all" : Number(value);
setLocalRangeSeconds(nextRange);
onRangeChange?.(nextRange);
}
@@ -136,7 +144,9 @@ export function LineSeriesChart({
<div className="flex justify-end">
<Select
value={
selectedRangeSeconds ? String(selectedRangeSeconds) : undefined
selectedRangeSeconds === undefined
? undefined
: String(selectedRangeSeconds)
}
onValueChange={handleRangeChange}
>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { LineSeriesChart } from "../LineSeriesChart";
import type { ChartSeries } from "../LineSeriesChart";
import { chartRangesThrough } from "../chartRanges";
@@ -38,6 +38,22 @@ describe("LineSeriesChart", () => {
).toHaveTextContent("2 hours");
});
it("offers all loaded values and reports that selection", () => {
const onRangeChange = vi.fn();
render(
<LineSeriesChart
series={[]}
rangeOptions={chartRangesThrough(3600)}
onRangeChange={onRangeChange}
/>,
);
fireEvent.click(screen.getByRole("combobox", { name: "Chart range" }));
fireEvent.click(screen.getByRole("option", { name: "All values" }));
expect(onRangeChange).toHaveBeenCalledWith("all");
});
it("renders with custom height", () => {
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
const { container } = render(
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
chartRangesThrough,
PROMETHEUS_WINDOW_VALUES,
rangeSecondsFromWindow,
} from "../chartRanges";
describe("chart ranges", () => {
it("maps every persisted Prometheus window to its display duration", () => {
expect(PROMETHEUS_WINDOW_VALUES).toEqual([
"5m",
"15m",
"30m",
"1h",
"3h",
"6h",
"12h",
"24h",
"2d",
"7d",
"14d",
"30d",
]);
expect(PROMETHEUS_WINDOW_VALUES.map(rangeSecondsFromWindow)).toEqual([
300, 900, 1800, 3600, 10800, 21600, 43200, 86400, 172800, 604800, 1209600,
2592000,
]);
});
it("offers all values after every finite range through the available history", () => {
expect(chartRangesThrough(86_400).at(-1)).toEqual({
value: "all",
label: "All values",
});
});
});
+59 -26
View File
@@ -1,18 +1,45 @@
export type ChartRangeValue = number | "all";
export interface ChartRangeOption {
value: number;
value: ChartRangeValue;
label: string;
}
/** Shared range choices used by every time-series chart. */
export const DEFAULT_CHART_RANGES: ChartRangeOption[] = [
{ value: 900, label: "15 minutes" },
{ value: 1800, label: "30 minutes" },
{ value: 3600, label: "1 hour" },
{ value: 21600, label: "6 hours" },
{ value: 86400, label: "24 hours" },
{ value: 604800, label: "7 days" },
interface FiniteChartRange extends ChartRangeOption {
key: string;
value: number;
}
/** Canonical finite windows for chart configuration and display filtering. */
const FINITE_CHART_RANGES: readonly FiniteChartRange[] = [
{ key: "5m", value: 300, label: "5 minutes" },
{ key: "15m", value: 900, label: "15 minutes" },
{ key: "30m", value: 1800, label: "30 minutes" },
{ key: "1h", value: 3600, label: "1 hour" },
{ key: "3h", value: 10800, label: "3 hours" },
{ key: "6h", value: 21600, label: "6 hours" },
{ key: "12h", value: 43200, label: "12 hours" },
{ key: "24h", value: 86400, label: "24 hours" },
{ key: "2d", value: 172800, label: "2 days" },
{ key: "7d", value: 604800, label: "7 days" },
{ key: "14d", value: 1209600, label: "14 days" },
{ key: "30d", value: 2592000, label: "30 days" },
];
/** Shared range choices used by every time-series chart. */
export const DEFAULT_CHART_RANGES: readonly ChartRangeOption[] =
FINITE_CHART_RANGES;
/** Symbolic range values persisted by Prometheus chart and mean widgets. */
export const PROMETHEUS_WINDOW_VALUES = FINITE_CHART_RANGES.map(
(range) => range.key,
);
export const ALL_VALUES_CHART_RANGE: ChartRangeOption = {
value: "all",
label: "All values",
};
function formatRangeLabel(seconds: number): string {
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
@@ -22,27 +49,33 @@ function formatRangeLabel(seconds: number): string {
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
return [DEFAULT_CHART_RANGES[0]];
return [DEFAULT_CHART_RANGES[0], ALL_VALUES_CHART_RANGE];
}
const ranges = DEFAULT_CHART_RANGES.filter(
const ranges = FINITE_CHART_RANGES.filter(
(range) => range.value < maxSeconds,
);
const exact = DEFAULT_CHART_RANGES.find(
(range) => range.value === maxSeconds,
);
return exact
? [...ranges, exact]
: [...ranges, { value: maxSeconds, label: formatRangeLabel(maxSeconds) }];
const exact = FINITE_CHART_RANGES.find((range) => range.value === maxSeconds);
return [
...(exact
? [...ranges, exact]
: [
...ranges,
{ value: maxSeconds, label: formatRangeLabel(maxSeconds) },
]),
ALL_VALUES_CHART_RANGE,
];
}
export function rangeSecondsFromWindow(window: unknown): number {
const values: Record<string, number> = {
"15m": 900,
"30m": 1800,
"1h": 3600,
"6h": 21600,
"24h": 86400,
"7d": 604800,
};
return values[String(window)] ?? 3600;
return (
FINITE_CHART_RANGES.find((range) => range.key === String(window))?.value ??
3600
);
}
/** Numeric chart windows suitable for sources with bounded local retention. */
export function numericChartRangesThrough(maxSeconds: number): number[] {
return FINITE_CHART_RANGES.flatMap((range) =>
range.value <= maxSeconds ? [range.value] : [],
);
}