feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract
This commit is contained in:
@@ -19,11 +19,13 @@ from typing import Any, Protocol
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
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 (
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
_map_sessions_to_activity_rows,
|
_map_sessions_to_activity_rows,
|
||||||
build_backup_dashboard_summary,
|
build_backup_dashboard_summary,
|
||||||
)
|
)
|
||||||
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
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.settings_store import SettingsStore, get_settings_store
|
||||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||||
from media_library_viewer_api.widgets.prometheus_range import (
|
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")
|
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
|
# Registries
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||||
"prometheus": PrometheusWidgetSource(),
|
"prometheus": PrometheusWidgetSource(),
|
||||||
|
"qbittorrent": QbittorrentWidgetSource(),
|
||||||
"alertmanager": AlertmanagerWidgetSource(),
|
"alertmanager": AlertmanagerWidgetSource(),
|
||||||
"jellyfin": JellyfinWidgetSource(),
|
"jellyfin": JellyfinWidgetSource(),
|
||||||
"ssh_tasks": SshTaskWidgetSource(),
|
"ssh_tasks": SshTaskWidgetSource(),
|
||||||
|
|||||||
@@ -988,3 +988,190 @@ async def test_prometheus_mean_adapter_requires_promql():
|
|||||||
)
|
)
|
||||||
result = await adapter.fetch(service, "mean", {"promql": ""})
|
result = await adapter.fetch(service, "mean", {"promql": ""})
|
||||||
assert result == {"error": "promql is required"}
|
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",
|
"jellyfin",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
|
"qbittorrent",
|
||||||
"ssh_tasks",
|
"ssh_tasks",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
|
|||||||
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||||
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
|
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
|
||||||
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
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 { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||||
import { StaticWidget } from "../widgets/StaticWidget";
|
import { StaticWidget } from "../widgets/StaticWidget";
|
||||||
import type {
|
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: {
|
jellyfin: {
|
||||||
serviceType: "jellyfin",
|
serviceType: "jellyfin",
|
||||||
name: "Jellyfin",
|
name: "Jellyfin",
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
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 type { ChartSeries } from "../components/LineSeriesChart";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
@@ -19,46 +12,6 @@ interface Props {
|
|||||||
description?: string;
|
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({
|
export function PrometheusChartWidget({
|
||||||
widget,
|
widget,
|
||||||
refreshIntervalMs,
|
refreshIntervalMs,
|
||||||
@@ -76,38 +29,7 @@ export function PrometheusChartWidget({
|
|||||||
<AlertDescription>{data.error}</AlertDescription>
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : series && series.length > 0 ? (
|
) : series && series.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<LineSeriesChart series={series} />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<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,6 +4,10 @@ export { PrometheusChartWidget } from "./PrometheusChartWidget";
|
|||||||
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
|
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
|
||||||
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
|
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
|
||||||
export { JellyfinWidget } from "./JellyfinWidget";
|
export { JellyfinWidget } from "./JellyfinWidget";
|
||||||
|
export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget";
|
||||||
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
|
export { QbittorrentActiveTorrentsWidget } from "./QbittorrentActiveTorrentsWidget";
|
||||||
|
export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget";
|
||||||
|
export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget";
|
||||||
export { SshTaskWidget } from "./SshTaskWidget";
|
export { SshTaskWidget } from "./SshTaskWidget";
|
||||||
export { StaticWidget } from "./StaticWidget";
|
export { StaticWidget } from "./StaticWidget";
|
||||||
|
|||||||
Reference in New Issue
Block a user