feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract

This commit is contained in:
Developer
2026-07-09 08:46:20 +00:00
parent e7bd0afdd1
commit 1fb12b8a0a
14 changed files with 831 additions and 81 deletions
@@ -19,11 +19,13 @@ from typing import Any, Protocol
import requests
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.prometheus_range import (
@@ -368,12 +370,75 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
logger.exception("failed to record ssh task timeout")
class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "qBittorrent widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
username = str(service.secrets.get("username") or "")
password = str(service.secrets.get("password") or "")
timeout = int(service.config.get("timeout_seconds") or 10)
if not base_url or not username or not password:
return {"error": "qBittorrent service is missing base_url, username, or password"}
client = QbittorrentClient(base_url, username, password, timeout)
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
server_state = data.get("server_state", {})
torrents = data.get("torrents", {})
if widget_kind == "totals":
by_state: dict[str, int] = {}
for t in torrents.values():
state = str(t.get("state", "unknown"))
by_state[state] = by_state.get(state, 0) + 1
return {"total": len(torrents), "by_state": by_state}
if widget_kind == "active":
active = [
{
"name": t.get("name"),
"state": t.get("state"),
"size": t.get("size"),
"progress": t.get("progress"),
"dl_speed": t.get("dlspeed"),
"up_speed": t.get("upspeed"),
}
for t in torrents.values()
if str(t.get("state", "")) in {"downloading", "uploading"}
]
return {"torrents": active}
if widget_kind == "speed":
dl = int(server_state.get("dl_info_speed", 0))
up = int(server_state.get("up_info_speed", 0))
ts = int(time.time())
store = QbittorrentSampleStore()
store.append(service.id, ts, dl, up)
samples = store.window(service.id)
series = [
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
]
return {"series": series}
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
except asyncio.TimeoutError:
return {"error": "qBittorrent data fetch timed out"}
except Exception as exc:
logger.exception("qbittorrent adapter failed")
return {"error": f"qBittorrent fetch failed: {exc}"}
# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"prometheus": PrometheusWidgetSource(),
"qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(),
+187
View File
@@ -988,3 +988,190 @@ async def test_prometheus_mean_adapter_requires_promql():
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": "promql is required"}
# ---------------------------------------------------------------------------
# qBittorrent widget source adapter
# ---------------------------------------------------------------------------
def _fake_qbit_maindata():
"""Return a mock maindata response (server_state + torrents dict)."""
return {
"server_state": {"dl_info_speed": 500000, "up_info_speed": 100000},
"torrents": {
"h1": {
"name": "Movie.mkv",
"state": "downloading",
"size": 1000,
"progress": 0.5,
"dlspeed": 500,
"upspeed": 10,
},
"h2": {
"name": "Show.mkv",
"state": "uploading",
"size": 2000,
"progress": 1.0,
"dlspeed": 0,
"upspeed": 100,
},
"h3": {
"name": "Queued",
"state": "queuedDL",
"size": 3000,
"progress": 0.0,
"dlspeed": 0,
"upspeed": 0,
},
"h4": {
"name": "Paused",
"state": "pausedDL",
"size": 4000,
"progress": 0.3,
"dlspeed": 0,
"upspeed": 0,
},
},
}
@pytest.mark.asyncio
async def test_qbittorrent_totals_counts_all_torrents():
"""Totals kind returns count of all listed items + by_state breakdown."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "totals", {})
assert result["total"] == 4
assert result["by_state"]["downloading"] == 1
assert result["by_state"]["uploading"] == 1
assert result["by_state"]["queuedDL"] == 1
assert result["by_state"]["pausedDL"] == 1
@pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only():
"""Active kind returns only downloading/uploading torrents (Q3)."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "active", {})
active = result["torrents"]
assert len(active) == 2
names = [t["name"] for t in active]
assert "Movie.mkv" in names
assert "Show.mkv" in names
# Queued and paused are excluded
assert "Queued" not in names
assert "Paused" not in names
@pytest.mark.asyncio
async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
"""Speed kind appends a sample and returns {series} with two labeled series."""
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
from media_library_viewer_api.services.service_data import ServiceDataHarness
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
# Isolated harness so we don't pollute the real DB
harness = ServiceDataHarness(base_dir=str(tmp_path))
harness.register(QBITTORRENT_CONCERN)
harness.run_migrations()
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-speed",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with (
patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client,
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
):
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
# Wire the mock store to a real isolated store
real_store = QbittorrentSampleStore(harness)
mock_store_cls.return_value = real_store
result = await adapter.fetch(service, "speed", {})
assert "series" in result
labels = [s["label"] for s in result["series"]]
assert labels == ["download", "upload"]
# The sample just appended should be present
dl_points = result["series"][0]["points"]
assert len(dl_points) >= 1
# timestamps multiplied by 1000 for JS epoch
assert dl_points[-1]["v"] == 500000
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_service():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
result = await adapter.fetch(None, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_credentials():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080"},
secrets={"username": "", "password": ""},
)
result = await adapter.fetch(service, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_timeout():
"""A timeout returns {error} rather than raising."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 1},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
import asyncio as _asyncio
async def _slow(*a, **kw):
await _asyncio.sleep(10)
# Make to_thread hang so wait_for times out
mock_client.return_value.maindata.side_effect = lambda: (_ for _ in ()).throw(TimeoutError())
result = await adapter.fetch(service, "totals", {})
assert "error" in result
@@ -0,0 +1,95 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
export interface SeriesPoint {
t: number;
v: number | null;
}
export interface ChartSeries {
label: string;
points: SeriesPoint[];
}
/** 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);
}
}
return [...map.values()].sort(
(a, b) => (a.time as number) - (b.time as number),
);
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
interface LineSeriesChartProps {
series: ChartSeries[];
height?: number;
}
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
export function LineSeriesChart({
series,
height = 300,
}: LineSeriesChartProps) {
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 tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
color: "hsl(var(--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
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { LineSeriesChart } from "../LineSeriesChart";
import type { ChartSeries } from "../LineSeriesChart";
describe("LineSeriesChart", () => {
it("renders without crashing with series data", () => {
const series: ChartSeries[] = [
{
label: "cpu",
points: [
{ t: 1000, v: 0.5 },
{ t: 2000, v: 0.8 },
],
},
];
const { container } = render(<LineSeriesChart series={series} />);
// ResponsiveContainer renders a wrapper div even in jsdom
expect(container.firstChild).not.toBeNull();
});
it("renders without crashing with empty series", () => {
const { container } = render(<LineSeriesChart series={[]} />);
expect(container.firstChild).not.toBeNull();
});
it("renders with custom height", () => {
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
const { container } = render(
<LineSeriesChart series={series} height={200} />,
);
expect(container.firstChild).not.toBeNull();
});
});
@@ -15,6 +15,7 @@ describe("service registry", () => {
"jellyfin",
"nextcloud",
"prometheus",
"qbittorrent",
"ssh_tasks",
]);
});
+37
View File
@@ -7,6 +7,9 @@ import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget";
import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget";
import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget";
import type {
@@ -157,6 +160,40 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
],
},
qbittorrent: {
serviceType: "qbittorrent",
name: "qBittorrent",
description: "Torrent client activity, speeds, and item counts.",
widgets: [
{
kind: "totals",
name: "Totals",
description: "Count of all listed torrents, broken down by state.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentTotalsWidget,
},
{
kind: "active",
name: "Active torrents",
description: "Torrents currently downloading or uploading.",
refreshIntervalMs: 15_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentActiveTorrentsWidget,
},
{
kind: "speed",
name: "Speed chart",
description: "Live download/upload speed over a short window.",
refreshIntervalMs: 5_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentSpeedWidget,
},
],
},
jellyfin: {
serviceType: "jellyfin",
name: "Jellyfin",
+3 -81
View File
@@ -1,17 +1,10 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart";
import type { ChartSeries } from "../components/LineSeriesChart";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface Props {
widget: WidgetInstance;
@@ -19,46 +12,6 @@ interface Props {
description?: string;
}
interface SeriesPoint {
t: number;
v: number | null;
}
interface ChartSeries {
label: string;
points: SeriesPoint[];
}
/** 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);
}
}
return [...map.values()].sort(
(a, b) => (a.time as number) - (b.time as number),
);
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
export function PrometheusChartWidget({
widget,
refreshIntervalMs,
@@ -76,38 +29,7 @@ export function PrometheusChartWidget({
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={mergeSeries(series)}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
color: "hsl(var(--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
/>
))}
</LineChart>
</ResponsiveContainer>
<LineSeriesChart series={series} />
) : (
<Alert>
<AlertDescription>
@@ -0,0 +1,76 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface ActiveTorrent {
name: string | null;
state: string | null;
size: number | null;
progress: number | null;
dl_speed: number | null;
up_speed: number | null;
}
function formatSpeed(bytesPerSec: number | null): string {
if (bytesPerSec === null || bytesPerSec <= 0) return "—";
const mb = bytesPerSec / 1_000_000;
if (mb >= 1) return `${mb.toFixed(1)} MB/s`;
return `${(bytesPerSec / 1000).toFixed(0)} KB/s`;
}
export function QbittorrentActiveTorrentsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const payload = data?.data as { torrents?: ActiveTorrent[] } | undefined;
const torrents = payload?.torrents ?? [];
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : torrents.length > 0 ? (
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
{torrents.map((t, i) => (
<li
key={`${t.name}-${i}`}
className="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-sm"
>
<span className="truncate">{t.name ?? "Unknown"}</span>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatSpeed(t.dl_speed)} {formatSpeed(t.up_speed)}
</span>
<Badge
variant={t.state === "downloading" ? "default" : "secondary"}
>
{t.state ?? "?"}
</Badge>
</div>
</li>
))}
</ul>
) : (
<div className="text-xs text-muted-foreground">No active torrents</div>
)}
</SectionCard>
);
}
@@ -0,0 +1,40 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart";
import type { ChartSeries } from "../components/LineSeriesChart";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function QbittorrentSpeedWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const series = data?.data?.series as ChartSeries[] | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-[220px] w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<LineSeriesChart series={series} height={220} />
) : (
<Alert>
<AlertDescription>No speed data yet</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,51 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function QbittorrentTotalsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const payload = data?.data as
| { total?: number; by_state?: Record<string, number> }
| undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-20 w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : payload ? (
<div className="space-y-3">
<div>
<div className="text-3xl font-semibold">{payload.total ?? 0}</div>
<div className="text-xs text-muted-foreground">Total torrents</div>
</div>
{payload.by_state && Object.keys(payload.by_state).length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{Object.entries(payload.by_state).map(([state, count]) => (
<Badge key={state} variant="secondary">
{state}: {count}
</Badge>
))}
</div>
) : null}
</div>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,103 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QbittorrentActiveTorrentsWidget } from "../QbittorrentActiveTorrentsWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "active",
title: "Active Torrents",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("QbittorrentActiveTorrentsWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
});
it("renders active torrent rows", () => {
mockData({
torrents: [
{
name: "Movie.mkv",
state: "downloading",
size: 1000,
progress: 0.5,
dl_speed: 500000,
up_speed: 1000,
},
{
name: "Show.mkv",
state: "uploading",
size: 2000,
progress: 1.0,
dl_speed: 0,
up_speed: 50000,
},
],
});
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(screen.getByText("Movie.mkv")).toBeInTheDocument();
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
expect(screen.getByText("downloading")).toBeInTheDocument();
expect(screen.getByText("uploading")).toBeInTheDocument();
});
it("shows empty state when no active torrents", () => {
mockData({ torrents: [] });
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(screen.getByText(/No active torrents/i)).toBeInTheDocument();
});
it("shows error alert on error", () => {
mockData(null, "qBittorrent fetch failed");
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QbittorrentSpeedWidget } from "../QbittorrentSpeedWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "speed",
title: "Speed Chart",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("QbittorrentSpeedWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
});
it("renders chart with series data", () => {
mockData({
series: [
{ label: "download", points: [{ t: 1000, v: 500000 }] },
{ label: "upload", points: [{ t: 1000, v: 100000 }] },
],
});
const { container } = render(
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
);
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
expect(container.firstChild).not.toBeNull();
});
it("shows empty state when no series", () => {
mockData({ series: [] });
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
expect(screen.getByText(/No speed data yet/i)).toBeInTheDocument();
});
it("shows error alert on error", () => {
mockData(null, "qBittorrent fetch failed");
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QbittorrentTotalsWidget } from "../QbittorrentTotalsWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "totals",
title: "Torrent Totals",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("QbittorrentTotalsWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
});
it("renders total count and state badges", () => {
mockData({
total: 4,
by_state: { downloading: 1, uploading: 1, pausedDL: 2 },
});
render(
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("downloading: 1")).toBeInTheDocument();
expect(screen.getByText("pausedDL: 2")).toBeInTheDocument();
});
it("shows error alert on error", () => {
mockData(null, "qBittorrent fetch failed");
render(
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
});
});
+4
View File
@@ -4,6 +4,10 @@ export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { QbittorrentActiveTorrentsWidget } from "./QbittorrentActiveTorrentsWidget";
export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget";
export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget";
export { SshTaskWidget } from "./SshTaskWidget";
export { StaticWidget } from "./StaticWidget";