Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d3ab87a9 | |||
| 1bf8a34a97 |
@@ -220,7 +220,12 @@ class QbittorrentClient:
|
|||||||
if fields is None:
|
if fields is None:
|
||||||
snap["torrents"].pop(hash_, None)
|
snap["torrents"].pop(hash_, None)
|
||||||
else:
|
else:
|
||||||
snap["torrents"][hash_] = fields
|
previous = snap["torrents"].get(hash_)
|
||||||
|
snap["torrents"][hash_] = (
|
||||||
|
{**previous, **fields}
|
||||||
|
if isinstance(previous, dict) and isinstance(fields, dict)
|
||||||
|
else fields
|
||||||
|
)
|
||||||
for hash_ in update.get("torrents_removed") or []:
|
for hash_ in update.get("torrents_removed") or []:
|
||||||
snap["torrents"].pop(hash_, None)
|
snap["torrents"].pop(hash_, None)
|
||||||
categories = update.get("categories")
|
categories = update.get("categories")
|
||||||
|
|||||||
@@ -125,13 +125,21 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
"rid": 10,
|
"rid": 10,
|
||||||
"full_update": True,
|
"full_update": True,
|
||||||
"server_state": {"dl_info_speed": 100},
|
"server_state": {"dl_info_speed": 100},
|
||||||
"torrents": {"a": {"name": "A", "state": "downloading"}},
|
"torrents": {
|
||||||
|
"a": {
|
||||||
|
"name": "A",
|
||||||
|
"state": "downloading",
|
||||||
|
"size": 1_024,
|
||||||
|
"progress": 0.5,
|
||||||
|
"dlspeed": 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
partial = {
|
partial = {
|
||||||
"rid": 11,
|
"rid": 11,
|
||||||
"full_update": False,
|
"full_update": False,
|
||||||
"server_state": {"dl_info_speed": 200},
|
"server_state": {"dl_info_speed": 200},
|
||||||
"torrents": {"a": {"name": "A", "state": "pausedDL"}},
|
"torrents": {"a": {"dlspeed": 200}},
|
||||||
}
|
}
|
||||||
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
|
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
|
||||||
|
|
||||||
@@ -143,7 +151,11 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
r2 = self.client.maindata()
|
r2 = self.client.maindata()
|
||||||
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10)
|
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10)
|
||||||
self.assertEqual(r2["server_state"]["dl_info_speed"], 200) # merged
|
self.assertEqual(r2["server_state"]["dl_info_speed"], 200) # merged
|
||||||
self.assertEqual(r2["torrents"]["a"]["state"], "pausedDL") # merged
|
self.assertEqual(r2["torrents"]["a"]["dlspeed"], 200)
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["name"], "A")
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["state"], "downloading")
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["size"], 1_024)
|
||||||
|
self.assertEqual(r2["torrents"]["a"]["progress"], 0.5)
|
||||||
|
|
||||||
def test_maindata_caches_concurrent_calls_within_ttl(self) -> None:
|
def test_maindata_caches_concurrent_calls_within_ttl(self) -> None:
|
||||||
"""Two calls within the TTL collapse to a single HTTP fetch."""
|
"""Two calls within the TTL collapse to a single HTTP fetch."""
|
||||||
@@ -227,8 +239,8 @@ class QbittorrentClientTests(unittest.TestCase):
|
|||||||
self.session.post.return_value = self._login_response()
|
self.session.post.return_value = self._login_response()
|
||||||
self.client._login()
|
self.client._login()
|
||||||
call_kwargs = self.session.post.call_args.kwargs
|
call_kwargs = self.session.post.call_args.kwargs
|
||||||
assert call_kwargs["timeout"] == (5.0, 5.0)
|
self.assertEqual(call_kwargs["timeout"], (5.0, 5.0))
|
||||||
assert not isinstance(call_kwargs["timeout"], int)
|
self.assertNotIsInstance(call_kwargs["timeout"], int)
|
||||||
|
|
||||||
def test_login_fails_message_names_bad_credentials(self) -> None:
|
def test_login_fails_message_names_bad_credentials(self) -> None:
|
||||||
"""'Fails.' body yields a clear 'invalid username or password' error."""
|
"""'Fails.' body yields a clear 'invalid username or password' error."""
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -321,6 +321,9 @@ These do not reference a service.
|
|||||||
- The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart.
|
- The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart.
|
||||||
- Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff.
|
- Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff.
|
||||||
- The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
|
- The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
|
||||||
|
- The qBittorrent client must merge incremental torrent patches with the prior
|
||||||
|
snapshot so active-transfer rows retain their name, size, progress, and state
|
||||||
|
when only throughput changes.
|
||||||
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, the shared selectable chart windows, an **All values** option that fetches every retained speed sample, and paginated scheduled-action history.
|
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, the shared selectable chart windows, an **All values** option that fetches every retained speed sample, and paginated scheduled-action history.
|
||||||
- Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials.
|
- Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials.
|
||||||
- Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior.
|
- Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior.
|
||||||
|
|||||||
@@ -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,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user