feat: unify chart range controls

This commit is contained in:
Developer
2026-07-14 17:00:32 +00:00
parent a541a4fd16
commit 03aece02b8
8 changed files with 235 additions and 96 deletions
+2
View File
@@ -46,6 +46,8 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
### Tables ### Tables
- All in-app time-series widgets should use the shared range-aware `LineSeriesChart` component so range controls, filtering, and display formatting remain consistent across Prometheus and qBittorrent charts.
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable` - Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
wrapper (`components/ui/data-table.tsx`). wrapper (`components/ui/data-table.tsx`).
- Parity is **visibility-only**: pagination, row selection, row click, and column - Parity is **visibility-only**: pagination, row selection, row click, and column
+133 -59
View File
@@ -1,17 +1,26 @@
import { useState } from "react";
import { import {
LineChart, CartesianGrid,
Line, Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts"; } from "recharts";
import { import {
type MetricUnit, Select,
type MetricScale, SelectContent,
metricScaleInfo, SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DEFAULT_CHART_RANGES, type ChartRangeOption } from "./chartRanges";
import {
formatScaled, formatScaled,
metricScaleInfo,
type MetricScale,
type MetricUnit,
} from "../lib/metricFormat"; } from "../lib/metricFormat";
export interface SeriesPoint { export interface SeriesPoint {
@@ -27,11 +36,11 @@ export interface ChartSeries {
/** Merge multiple time-series into a single recharts-friendly array. */ /** Merge multiple time-series into a single recharts-friendly array. */
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] { function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
const map = new Map<number, Record<string, unknown>>(); const map = new Map<number, Record<string, unknown>>();
for (const s of series) { for (const seriesItem of series) {
for (const p of s.points) { for (const point of seriesItem.points) {
const existing = map.get(p.t) ?? { time: p.t }; const existing = map.get(point.t) ?? { time: point.t };
existing[s.label] = p.v; existing[seriesItem.label] = point.v;
map.set(p.t, existing); map.set(point.t, existing);
} }
} }
return [...map.values()].sort( return [...map.values()].sort(
@@ -61,66 +70,131 @@ interface LineSeriesChartProps {
unit?: MetricUnit; unit?: MetricUnit;
/** "auto" picks a prefix from the data magnitude; k/m/g/t force one. */ /** "auto" picks a prefix from the data magnitude; k/m/g/t force one. */
scale?: MetricScale; 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;
/** Controlled range for consumers that refetch when the selection changes. */
rangeSeconds?: number;
onRangeChange?: (rangeSeconds: number) => void;
} }
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */ /** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
export function LineSeriesChart({ export function LineSeriesChart({
series, series,
height = 300, height = 300,
unit = "none", unit = "none",
scale = "auto", scale = "auto",
rangeOptions = DEFAULT_CHART_RANGES,
defaultRangeSeconds,
rangeSeconds,
onRangeChange,
}: LineSeriesChartProps) { }: LineSeriesChartProps) {
// Choose ONE (divisor, suffix) from the series magnitude so the axis and const initialRange =
// tooltip stay consistent (e.g. all values shown in MB/s). defaultRangeSeconds ?? rangeOptions[rangeOptions.length - 1]?.value;
const maxAbs = series.reduce((m, s) => { const [localRangeSeconds, setLocalRangeSeconds] = useState(initialRange);
for (const p of s.points) { const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
const v = p.v == null ? 0 : Math.abs(p.v); const latestTimestamp = series.reduce(
if (v > m) m = v; (max, seriesItem) =>
seriesItem.points.reduce(
(seriesMax, point) => Math.max(seriesMax, point.t),
max,
),
0,
);
const cutoff = selectedRangeSeconds
? 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 m; return max;
}, 0); }, 0);
const scaleInfo = metricScaleInfo(maxAbs, unit, scale); const scaleInfo = metricScaleInfo(maxAbs, unit, scale);
const fmt = (v: number | null | undefined) => const formatValue = (value: number | null | undefined) =>
formatScaled(v, scaleInfo, unit); formatScaled(value, scaleInfo, unit);
function handleRangeChange(value: string) {
const nextRange = Number(value);
setLocalRangeSeconds(nextRange);
onRangeChange?.(nextRange);
}
return ( return (
<ResponsiveContainer width="100%" height={height}> <div className="space-y-2">
<LineChart data={mergeSeries(series)}> {rangeOptions.length > 0 && (
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <div className="flex justify-end">
<XAxis <Select
dataKey="time" value={
tickFormatter={formatTime} selectedRangeSeconds ? String(selectedRangeSeconds) : undefined
tick={{ fontSize: 11 }} }
className="fill-muted-foreground" onValueChange={handleRangeChange}
/> >
<YAxis <SelectTrigger
tickFormatter={fmt} className="w-[150px]"
tick={{ fontSize: 11 }} size="sm"
width={56} aria-label="Chart range"
className="fill-muted-foreground" >
/> <SelectValue placeholder="Chart range" />
<Tooltip </SelectTrigger>
labelFormatter={(label) => formatTime(Number(label))} <SelectContent>
formatter={(value) => fmt(Number(value))} {rangeOptions.map((range) => (
contentStyle={{ <SelectItem key={range.value} value={String(range.value)}>
backgroundColor: "var(--color-popover)", {range.label}
border: "1px solid var(--color-border)", </SelectItem>
borderRadius: "0.5rem", ))}
color: "var(--color-popover-foreground)", </SelectContent>
}} </Select>
/> </div>
{series.map((s, i) => ( )}
<Line <ResponsiveContainer width="100%" height={height}>
key={s.label} <LineChart data={mergeSeries(visibleSeries)}>
type="monotone" <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
dataKey={s.label} <XAxis
stroke={CHART_COLORS[i % CHART_COLORS.length]} dataKey="time"
dot={false} tickFormatter={formatTime}
strokeWidth={2} tick={{ fontSize: 11 }}
connectNulls className="fill-muted-foreground"
/> />
))} <YAxis
</LineChart> tickFormatter={formatValue}
</ResponsiveContainer> 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>
); );
} }
@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { LineSeriesChart } from "../LineSeriesChart"; import { LineSeriesChart } from "../LineSeriesChart";
import type { ChartSeries } from "../LineSeriesChart"; import type { ChartSeries } from "../LineSeriesChart";
import { chartRangesThrough } from "../chartRanges";
describe("LineSeriesChart", () => { describe("LineSeriesChart", () => {
it("renders without crashing with series data", () => { it("renders without crashing with series data", () => {
@@ -24,6 +25,19 @@ describe("LineSeriesChart", () => {
expect(container.firstChild).not.toBeNull(); expect(container.firstChild).not.toBeNull();
}); });
it("renders a configurable displayed data range", () => {
render(
<LineSeriesChart
series={[]}
rangeOptions={chartRangesThrough(7200)}
defaultRangeSeconds={7200}
/>,
);
expect(
screen.getByRole("combobox", { name: "Chart range" }),
).toHaveTextContent("2 hours");
});
it("renders with custom height", () => { it("renders with custom height", () => {
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }]; const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
const { container } = render( const { container } = render(
+48
View File
@@ -0,0 +1,48 @@
export interface ChartRangeOption {
value: number;
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" },
];
function formatRangeLabel(seconds: number): string {
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
if (seconds % 60 === 0) return `${seconds / 60} minutes`;
return `${seconds} seconds`;
}
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
return [DEFAULT_CHART_RANGES[0]];
}
const ranges = DEFAULT_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) }];
}
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;
}
+16 -4
View File
@@ -221,10 +221,20 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
name: "Speed chart", name: "Speed chart",
description: "Live download/upload speed over a short window.", description: "Live download/upload speed over a short window.",
refreshIntervalMs: 15_000, refreshIntervalMs: 15_000,
defaultConfig: { unit: "bytes_per_sec", scale: "auto" }, defaultConfig: {
window_seconds: 1800,
unit: "bytes_per_sec",
scale: "auto",
},
configSchema: { configSchema: {
type: "object", type: "object",
properties: { ...AXIS_FORMAT_PROPERTIES }, properties: {
window_seconds: {
type: "integer",
description: "Maximum data window available to the chart",
},
...AXIS_FORMAT_PROPERTIES,
},
required: [], required: [],
}, },
component: QbittorrentSpeedWidget, component: QbittorrentSpeedWidget,
@@ -257,7 +267,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
{ {
kind: "stat", kind: "stat",
name: "Request stat", name: "Request stat",
description: "A single Jellyseerr request statistic (e.g. pending requests).", description:
"A single Jellyseerr request statistic (e.g. pending requests).",
refreshIntervalMs: 60_000, refreshIntervalMs: 60_000,
defaultConfig: { stat: "pending" }, defaultConfig: { stat: "pending" },
configSchema: { configSchema: {
@@ -283,7 +294,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
{ {
kind: "stats_overview", kind: "stats_overview",
name: "Requests overview", name: "Requests overview",
description: "All Jellyseerr request stats plus a recent-requests list.", description:
"All Jellyseerr request stats plus a recent-requests list.",
refreshIntervalMs: 60_000, refreshIntervalMs: 60_000,
defaultConfig: {}, defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] }, configSchema: { type: "object", properties: {}, required: [] },
@@ -1,6 +1,7 @@
import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react"; import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { LineSeriesChart } from "../../components/LineSeriesChart"; import { LineSeriesChart } from "../../components/LineSeriesChart";
import { chartRangesThrough } from "../../components/chartRanges";
import { import {
useRunSchedulerAction, useRunSchedulerAction,
useSchedulerRuns, useSchedulerRuns,
@@ -12,23 +13,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const WINDOWS = [
{ 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" },
];
function formatTimestamp(value: number | null): string { function formatTimestamp(value: number | null): string {
return value ? new Date(value * 1000).toLocaleString() : "Never"; return value ? new Date(value * 1000).toLocaleString() : "Never";
} }
@@ -111,32 +97,24 @@ export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
)} )}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Speed history Speed history
</CardTitle> </CardTitle>
<Select
value={String(windowSeconds)}
onValueChange={(value) => setWindowSeconds(Number(value))}
>
<SelectTrigger className="w-[150px]" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((window) => (
<SelectItem key={window.value} value={String(window.value)}>
{window.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{samples.isLoading ? ( {samples.isLoading ? (
<Skeleton className="h-[300px] w-full" /> <Skeleton className="h-[300px] w-full" />
) : ( ) : (
<LineSeriesChart series={chartSeries} unit="bytes" height={300} /> <LineSeriesChart
series={chartSeries}
unit="bytes"
height={300}
rangeOptions={chartRangesThrough(86400)}
rangeSeconds={windowSeconds}
onRangeChange={setWindowSeconds}
/>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,6 +1,10 @@
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart"; import { LineSeriesChart } from "../components/LineSeriesChart";
import {
chartRangesThrough,
rangeSecondsFromWindow,
} from "../components/chartRanges";
import type { ChartSeries } from "../components/LineSeriesChart"; import type { ChartSeries } from "../components/LineSeriesChart";
import type { MetricScale, MetricUnit } from "../lib/metricFormat"; import type { MetricScale, MetricUnit } from "../lib/metricFormat";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
@@ -20,6 +24,7 @@ export function MetricChartWidget({
}: Props) { }: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const series = data?.data?.series as ChartSeries[] | undefined; const series = data?.data?.series as ChartSeries[] | undefined;
const maxRangeSeconds = rangeSecondsFromWindow(widget.config.window);
return ( return (
<SectionCard title={widget.title} description={description}> <SectionCard title={widget.title} description={description}>
@@ -34,6 +39,8 @@ export function MetricChartWidget({
series={series} series={series}
unit={widget.config.unit as MetricUnit} unit={widget.config.unit as MetricUnit}
scale={widget.config.scale as MetricScale} scale={widget.config.scale as MetricScale}
rangeOptions={chartRangesThrough(maxRangeSeconds)}
defaultRangeSeconds={maxRangeSeconds}
/> />
) : ( ) : (
<Alert> <Alert>
@@ -1,6 +1,7 @@
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart"; import { LineSeriesChart } from "../components/LineSeriesChart";
import { chartRangesThrough } from "../components/chartRanges";
import type { ChartSeries } from "../components/LineSeriesChart"; import type { ChartSeries } from "../components/LineSeriesChart";
import type { MetricScale, MetricUnit } from "../lib/metricFormat"; import type { MetricScale, MetricUnit } from "../lib/metricFormat";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
@@ -23,6 +24,7 @@ export function QbittorrentSpeedWidget({
// Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …). // Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …).
const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec"; const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec";
const scale = (widget.config.scale as MetricScale) || "auto"; const scale = (widget.config.scale as MetricScale) || "auto";
const maxRangeSeconds = Number(widget.config.window_seconds) || 1800;
return ( return (
<SectionCard title={widget.title} description={description}> <SectionCard title={widget.title} description={description}>
@@ -38,6 +40,8 @@ export function QbittorrentSpeedWidget({
unit={unit} unit={unit}
scale={scale} scale={scale}
height={220} height={220}
rangeOptions={chartRangesThrough(maxRangeSeconds)}
defaultRangeSeconds={maxRangeSeconds}
/> />
) : ( ) : (
<Alert> <Alert>