feat: unify chart range controls
This commit is contained in:
@@ -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<string, unknown>[] {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
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 (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={mergeSeries(series)}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={formatTime}
|
||||
tick={{ fontSize: 11 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={fmt}
|
||||
tick={{ fontSize: 11 }}
|
||||
width={56}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(label) => 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) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
<div className="space-y-2">
|
||||
{rangeOptions.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Select
|
||||
value={
|
||||
selectedRangeSeconds ? String(selectedRangeSeconds) : undefined
|
||||
}
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<LineSeriesChart
|
||||
series={[]}
|
||||
rangeOptions={chartRangesThrough(7200)}
|
||||
defaultRangeSeconds={7200}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -221,10 +221,20 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
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<string, ServiceBinding> = {
|
||||
{
|
||||
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<string, ServiceBinding> = {
|
||||
{
|
||||
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: [] },
|
||||
|
||||
@@ -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 }) {
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Speed history
|
||||
</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>
|
||||
<CardContent>
|
||||
{samples.isLoading ? (
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
@@ -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 (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
|
||||
@@ -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 (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
@@ -38,6 +40,8 @@ export function QbittorrentSpeedWidget({
|
||||
unit={unit}
|
||||
scale={scale}
|
||||
height={220}
|
||||
rangeOptions={chartRangesThrough(maxRangeSeconds)}
|
||||
defaultRangeSeconds={maxRangeSeconds}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
|
||||
Reference in New Issue
Block a user