diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index fbb1e12..d638719 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -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(), diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 7b6754e..670f652 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -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 diff --git a/frontend/src/components/LineSeriesChart.tsx b/frontend/src/components/LineSeriesChart.tsx new file mode 100644 index 0000000..7720db7 --- /dev/null +++ b/frontend/src/components/LineSeriesChart.tsx @@ -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[] { + 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); + } + } + 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 ( + + + + + + 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) => ( + + ))} + + + ); +} diff --git a/frontend/src/components/__tests__/LineSeriesChart.test.tsx b/frontend/src/components/__tests__/LineSeriesChart.test.tsx new file mode 100644 index 0000000..03ccb66 --- /dev/null +++ b/frontend/src/components/__tests__/LineSeriesChart.test.tsx @@ -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(); + // ResponsiveContainer renders a wrapper div even in jsdom + expect(container.firstChild).not.toBeNull(); + }); + + it("renders without crashing with empty series", () => { + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + }); + + it("renders with custom height", () => { + const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }]; + const { container } = render( + , + ); + expect(container.firstChild).not.toBeNull(); + }); +}); diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts index 1d745a8..1377048 100644 --- a/frontend/src/integrations/registry.test.ts +++ b/frontend/src/integrations/registry.test.ts @@ -15,6 +15,7 @@ describe("service registry", () => { "jellyfin", "nextcloud", "prometheus", + "qbittorrent", "ssh_tasks", ]); }); diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index add5c40..5fbc1e3 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -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 = { }, ], }, + 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", diff --git a/frontend/src/widgets/PrometheusChartWidget.tsx b/frontend/src/widgets/PrometheusChartWidget.tsx index 5931f37..d1f54dd 100644 --- a/frontend/src/widgets/PrometheusChartWidget.tsx +++ b/frontend/src/widgets/PrometheusChartWidget.tsx @@ -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[] { - 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); - } - } - 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({ {data.error} ) : series && series.length > 0 ? ( - - - - - - 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) => ( - - ))} - - + ) : ( diff --git a/frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx b/frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx new file mode 100644 index 0000000..f3b207d --- /dev/null +++ b/frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx @@ -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 ( + + {isLoading && !data ? ( +
+ + +
+ ) : data?.error ? ( + + {data.error} + + ) : torrents.length > 0 ? ( +
    + {torrents.map((t, i) => ( +
  • + {t.name ?? "Unknown"} +
    + + ↓{formatSpeed(t.dl_speed)} ↑{formatSpeed(t.up_speed)} + + + {t.state ?? "?"} + +
    +
  • + ))} +
+ ) : ( +
No active torrents
+ )} +
+ ); +} diff --git a/frontend/src/widgets/QbittorrentSpeedWidget.tsx b/frontend/src/widgets/QbittorrentSpeedWidget.tsx new file mode 100644 index 0000000..3c91359 --- /dev/null +++ b/frontend/src/widgets/QbittorrentSpeedWidget.tsx @@ -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 ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : series && series.length > 0 ? ( + + ) : ( + + No speed data yet + + )} + + ); +} diff --git a/frontend/src/widgets/QbittorrentTotalsWidget.tsx b/frontend/src/widgets/QbittorrentTotalsWidget.tsx new file mode 100644 index 0000000..6f8f4d9 --- /dev/null +++ b/frontend/src/widgets/QbittorrentTotalsWidget.tsx @@ -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 } + | undefined; + + return ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : payload ? ( +
+
+
{payload.total ?? 0}
+
Total torrents
+
+ {payload.by_state && Object.keys(payload.by_state).length > 0 ? ( +
+ {Object.entries(payload.by_state).map(([state, count]) => ( + + {state}: {count} + + ))} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx b/frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx new file mode 100644 index 0000000..923fcd5 --- /dev/null +++ b/frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx @@ -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); +} + +describe("QbittorrentActiveTorrentsWidget", () => { + it("renders skeleton while loading", () => { + vi.mocked(useWidgets.useWidgetData).mockReturnValue({ + data: undefined, + isLoading: true, + } as unknown as ReturnType); + render( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByText(/No active torrents/i)).toBeInTheDocument(); + }); + + it("shows error alert on error", () => { + mockData(null, "qBittorrent fetch failed"); + render( + , + ); + expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx b/frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx new file mode 100644 index 0000000..11d7a99 --- /dev/null +++ b/frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx @@ -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); +} + +describe("QbittorrentSpeedWidget", () => { + it("renders skeleton while loading", () => { + vi.mocked(useWidgets.useWidgetData).mockReturnValue({ + data: undefined, + isLoading: true, + } as unknown as ReturnType); + render(); + 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( + , + ); + expect(screen.getByText("Speed Chart")).toBeInTheDocument(); + expect(container.firstChild).not.toBeNull(); + }); + + it("shows empty state when no series", () => { + mockData({ series: [] }); + render(); + expect(screen.getByText(/No speed data yet/i)).toBeInTheDocument(); + }); + + it("shows error alert on error", () => { + mockData(null, "qBittorrent fetch failed"); + render(); + expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx b/frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx new file mode 100644 index 0000000..914ec80 --- /dev/null +++ b/frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx @@ -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); +} + +describe("QbittorrentTotalsWidget", () => { + it("renders skeleton while loading", () => { + vi.mocked(useWidgets.useWidgetData).mockReturnValue({ + data: undefined, + isLoading: true, + } as unknown as ReturnType); + render( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index a0aba87..398ed38 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -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";