diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 8953ff8..fa46bd2 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -46,6 +46,8 @@ fully removed (web-ui-rework; see decision log 2026-06-17). ### 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` wrapper (`components/ui/data-table.tsx`). - Parity is **visibility-only**: pagination, row selection, row click, and column diff --git a/frontend/src/components/LineSeriesChart.tsx b/frontend/src/components/LineSeriesChart.tsx index f65e649..7d33810 100644 --- a/frontend/src/components/LineSeriesChart.tsx +++ b/frontend/src/components/LineSeriesChart.tsx @@ -1,17 +1,26 @@ +import { useState } from "react"; import { - LineChart, + CartesianGrid, Line, + LineChart, + ResponsiveContainer, + Tooltip, XAxis, YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, } from "recharts"; import { - type MetricUnit, - type MetricScale, - metricScaleInfo, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DEFAULT_CHART_RANGES, type ChartRangeOption } from "./chartRanges"; +import { formatScaled, + metricScaleInfo, + type MetricScale, + type MetricUnit, } from "../lib/metricFormat"; export interface SeriesPoint { @@ -27,11 +36,11 @@ export interface ChartSeries { /** Merge multiple time-series into a single recharts-friendly array. */ function mergeSeries(series: ChartSeries[]): Record[] { const map = new Map>(); - for (const s of series) { - for (const p of s.points) { - const existing = map.get(p.t) ?? { time: p.t }; - existing[s.label] = p.v; - map.set(p.t, existing); + 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( @@ -61,66 +70,131 @@ interface LineSeriesChartProps { 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[]; + /** 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({ series, height = 300, unit = "none", scale = "auto", + rangeOptions = DEFAULT_CHART_RANGES, + defaultRangeSeconds, + rangeSeconds, + onRangeChange, }: LineSeriesChartProps) { - // Choose ONE (divisor, suffix) from the series magnitude so the axis and - // tooltip stay consistent (e.g. all values shown in MB/s). - const maxAbs = series.reduce((m, s) => { - for (const p of s.points) { - const v = p.v == null ? 0 : Math.abs(p.v); - if (v > m) m = v; + const initialRange = + defaultRangeSeconds ?? rangeOptions[rangeOptions.length - 1]?.value; + const [localRangeSeconds, setLocalRangeSeconds] = useState(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 = 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); const scaleInfo = metricScaleInfo(maxAbs, unit, scale); - const fmt = (v: number | null | undefined) => - formatScaled(v, scaleInfo, unit); + const formatValue = (value: number | null | undefined) => + formatScaled(value, scaleInfo, unit); + + function handleRangeChange(value: string) { + const nextRange = Number(value); + setLocalRangeSeconds(nextRange); + onRangeChange?.(nextRange); + } return ( - - - - - - formatTime(Number(label))} - formatter={(value) => fmt(Number(value))} - contentStyle={{ - backgroundColor: "var(--color-popover)", - border: "1px solid var(--color-border)", - borderRadius: "0.5rem", - color: "var(--color-popover-foreground)", - }} - /> - {series.map((s, i) => ( - + {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) => ( + + ))} +
+
+ ); } diff --git a/frontend/src/components/__tests__/LineSeriesChart.test.tsx b/frontend/src/components/__tests__/LineSeriesChart.test.tsx index 03ccb66..fc72fe3 100644 --- a/frontend/src/components/__tests__/LineSeriesChart.test.tsx +++ b/frontend/src/components/__tests__/LineSeriesChart.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { LineSeriesChart } from "../LineSeriesChart"; import type { ChartSeries } from "../LineSeriesChart"; +import { chartRangesThrough } from "../chartRanges"; describe("LineSeriesChart", () => { it("renders without crashing with series data", () => { @@ -24,6 +25,19 @@ describe("LineSeriesChart", () => { expect(container.firstChild).not.toBeNull(); }); + it("renders a configurable displayed data range", () => { + render( + , + ); + expect( + screen.getByRole("combobox", { name: "Chart range" }), + ).toHaveTextContent("2 hours"); + }); + it("renders with custom height", () => { const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }]; const { container } = render( diff --git a/frontend/src/components/chartRanges.ts b/frontend/src/components/chartRanges.ts new file mode 100644 index 0000000..2bfa13c --- /dev/null +++ b/frontend/src/components/chartRanges.ts @@ -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 = { + "15m": 900, + "30m": 1800, + "1h": 3600, + "6h": 21600, + "24h": 86400, + "7d": 604800, + }; + return values[String(window)] ?? 3600; +} diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 50999c9..442c697 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -221,10 +221,20 @@ export const SERVICE_REGISTRY: Record = { name: "Speed chart", description: "Live download/upload speed over a short window.", refreshIntervalMs: 15_000, - defaultConfig: { unit: "bytes_per_sec", scale: "auto" }, + defaultConfig: { + window_seconds: 1800, + unit: "bytes_per_sec", + scale: "auto", + }, configSchema: { type: "object", - properties: { ...AXIS_FORMAT_PROPERTIES }, + properties: { + window_seconds: { + type: "integer", + description: "Maximum data window available to the chart", + }, + ...AXIS_FORMAT_PROPERTIES, + }, required: [], }, component: QbittorrentSpeedWidget, @@ -257,7 +267,8 @@ export const SERVICE_REGISTRY: Record = { { kind: "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, defaultConfig: { stat: "pending" }, configSchema: { @@ -283,7 +294,8 @@ export const SERVICE_REGISTRY: Record = { { kind: "stats_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, defaultConfig: {}, configSchema: { type: "object", properties: {}, required: [] }, diff --git a/frontend/src/pages/service-tabs/QbittorrentTab.tsx b/frontend/src/pages/service-tabs/QbittorrentTab.tsx index 8f3383d..d44f28f 100644 --- a/frontend/src/pages/service-tabs/QbittorrentTab.tsx +++ b/frontend/src/pages/service-tabs/QbittorrentTab.tsx @@ -1,6 +1,7 @@ import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react"; import { useState } from "react"; import { LineSeriesChart } from "../../components/LineSeriesChart"; +import { chartRangesThrough } from "../../components/chartRanges"; import { useRunSchedulerAction, useSchedulerRuns, @@ -12,23 +13,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; 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"; -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 { return value ? new Date(value * 1000).toLocaleString() : "Never"; } @@ -111,32 +97,24 @@ export function QbittorrentTab({ instance }: { instance: ServiceInstance }) { )} - + Speed history - {samples.isLoading ? ( ) : ( - + )} diff --git a/frontend/src/widgets/MetricChartWidget.tsx b/frontend/src/widgets/MetricChartWidget.tsx index 8a45d77..6f41673 100644 --- a/frontend/src/widgets/MetricChartWidget.tsx +++ b/frontend/src/widgets/MetricChartWidget.tsx @@ -1,6 +1,10 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; import { LineSeriesChart } from "../components/LineSeriesChart"; +import { + chartRangesThrough, + rangeSecondsFromWindow, +} from "../components/chartRanges"; import type { ChartSeries } from "../components/LineSeriesChart"; import type { MetricScale, MetricUnit } from "../lib/metricFormat"; import { SectionCard } from "../components/SectionCard"; @@ -20,6 +24,7 @@ export function MetricChartWidget({ }: Props) { const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const series = data?.data?.series as ChartSeries[] | undefined; + const maxRangeSeconds = rangeSecondsFromWindow(widget.config.window); return ( @@ -34,6 +39,8 @@ export function MetricChartWidget({ series={series} unit={widget.config.unit as MetricUnit} scale={widget.config.scale as MetricScale} + rangeOptions={chartRangesThrough(maxRangeSeconds)} + defaultRangeSeconds={maxRangeSeconds} /> ) : ( diff --git a/frontend/src/widgets/QbittorrentSpeedWidget.tsx b/frontend/src/widgets/QbittorrentSpeedWidget.tsx index 6a3128f..0e562ec 100644 --- a/frontend/src/widgets/QbittorrentSpeedWidget.tsx +++ b/frontend/src/widgets/QbittorrentSpeedWidget.tsx @@ -1,6 +1,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; import { LineSeriesChart } from "../components/LineSeriesChart"; +import { chartRangesThrough } from "../components/chartRanges"; import type { ChartSeries } from "../components/LineSeriesChart"; import type { MetricScale, MetricUnit } from "../lib/metricFormat"; 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, …). const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec"; const scale = (widget.config.scale as MetricScale) || "auto"; + const maxRangeSeconds = Number(widget.config.window_seconds) || 1800; return ( @@ -38,6 +40,8 @@ export function QbittorrentSpeedWidget({ unit={unit} scale={scale} height={220} + rangeOptions={chartRangesThrough(maxRangeSeconds)} + defaultRangeSeconds={maxRangeSeconds} /> ) : (