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
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user