fix(charting): remove duplicate range selector

This commit is contained in:
Developer
2026-07-15 19:15:06 +00:00
parent e0f66a51f7
commit 1bf8a34a97
7 changed files with 23 additions and 22 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ 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. The selector offers 5 minutes, 15 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours, 24 hours, 2 days, 7 days, 14 days, 30 days, and **All values**; sources with bounded local retention expose the finite windows they can retain plus all retained values. - All in-app time-series widgets should use the shared range-aware `LineSeriesChart` component so filtering and display formatting remain consistent across Prometheus and qBittorrent charts. A dashboard widget's configured window is its single source of range selection and the card renders the complete configured response; the standalone qBittorrent service-history page retains an interactive selector with **All values** for all retained samples.
- 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`).
+7 -3
View File
@@ -76,6 +76,8 @@ interface LineSeriesChartProps {
scale?: MetricScale; scale?: MetricScale;
/** Available displayed time ranges. Defaults to the shared range choices. */ /** Available displayed time ranges. Defaults to the shared range choices. */
rangeOptions?: readonly ChartRangeOption[]; rangeOptions?: readonly ChartRangeOption[];
/** Whether to render the interactive range selector. */
showRangeSelector?: boolean;
/** Initial uncontrolled range. Defaults to the largest numeric option. */ /** Initial uncontrolled range. Defaults to the largest numeric option. */
defaultRangeSeconds?: ChartRangeValue; defaultRangeSeconds?: ChartRangeValue;
/** Controlled range for consumers that refetch when the selection changes. */ /** Controlled range for consumers that refetch when the selection changes. */
@@ -90,6 +92,7 @@ export function LineSeriesChart({
unit = "none", unit = "none",
scale = "auto", scale = "auto",
rangeOptions = DEFAULT_CHART_RANGES, rangeOptions = DEFAULT_CHART_RANGES,
showRangeSelector = true,
defaultRangeSeconds, defaultRangeSeconds,
rangeSeconds, rangeSeconds,
onRangeChange, onRangeChange,
@@ -98,8 +101,9 @@ export function LineSeriesChart({
defaultRangeSeconds ?? defaultRangeSeconds ??
[...rangeOptions].reverse().find((range) => typeof range.value === "number") [...rangeOptions].reverse().find((range) => typeof range.value === "number")
?.value; ?.value;
const [localRangeSeconds, setLocalRangeSeconds] = const [localRangeSeconds, setLocalRangeSeconds] = useState<
useState<ChartRangeValue | undefined>(initialRange); ChartRangeValue | undefined
>(initialRange);
const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds; const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
const latestTimestamp = series.reduce( const latestTimestamp = series.reduce(
(max, seriesItem) => (max, seriesItem) =>
@@ -140,7 +144,7 @@ export function LineSeriesChart({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{rangeOptions.length > 0 && ( {showRangeSelector && rangeOptions.length > 0 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Select <Select
value={ value={
@@ -38,6 +38,13 @@ describe("LineSeriesChart", () => {
).toHaveTextContent("2 hours"); ).toHaveTextContent("2 hours");
}); });
it("can hide the interactive selector for configured widgets", () => {
render(<LineSeriesChart series={[]} showRangeSelector={false} />);
expect(
screen.queryByRole("combobox", { name: "Chart range" }),
).not.toBeInTheDocument();
});
it("offers all loaded values and reports that selection", () => { it("offers all loaded values and reports that selection", () => {
const onRangeChange = vi.fn(); const onRangeChange = vi.fn();
render( render(
+1 -7
View File
@@ -1,10 +1,6 @@
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";
@@ -24,7 +20,6 @@ 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}>
@@ -39,8 +34,7 @@ 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)} showRangeSelector={false}
defaultRangeSeconds={maxRangeSeconds}
/> />
) : ( ) : (
<Alert> <Alert>
@@ -1,10 +1,6 @@
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,
type ChartRangeValue,
} 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";
@@ -27,11 +23,6 @@ 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 configuredRange = widget.config.window_seconds;
const maxRangeSeconds =
configuredRange === "all" ? 86_400 : Number(configuredRange) || 1800;
const defaultRangeSeconds: ChartRangeValue =
configuredRange === "all" ? "all" : maxRangeSeconds;
return ( return (
<SectionCard title={widget.title} description={description}> <SectionCard title={widget.title} description={description}>
@@ -47,8 +38,7 @@ export function QbittorrentSpeedWidget({
unit={unit} unit={unit}
scale={scale} scale={scale}
height={220} height={220}
rangeOptions={chartRangesThrough(maxRangeSeconds)} showRangeSelector={false}
defaultRangeSeconds={defaultRangeSeconds}
/> />
) : ( ) : (
<Alert> <Alert>
@@ -56,6 +56,9 @@ describe("MetricChartWidget", () => {
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />); render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
// recharts renders an SVG; the title from SectionCard should be present. // recharts renders an SVG; the title from SectionCard should be present.
expect(screen.getByText("CPU Usage")).toBeInTheDocument(); expect(screen.getByText("CPU Usage")).toBeInTheDocument();
expect(
screen.queryByRole("combobox", { name: "Chart range" }),
).not.toBeInTheDocument();
}); });
it("shows error Alert on error", () => { it("shows error Alert on error", () => {
@@ -52,6 +52,9 @@ describe("QbittorrentSpeedWidget", () => {
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />, <QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
); );
expect(screen.getByText("Speed Chart")).toBeInTheDocument(); expect(screen.getByText("Speed Chart")).toBeInTheDocument();
expect(
screen.queryByRole("combobox", { name: "Chart range" }),
).not.toBeInTheDocument();
expect(container.firstChild).not.toBeNull(); expect(container.firstChild).not.toBeNull();
}); });