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

This commit is contained in:
Developer
2026-07-09 08:46:20 +00:00
parent e7bd0afdd1
commit 1fb12b8a0a
14 changed files with 831 additions and 81 deletions
@@ -0,0 +1,95 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
export interface SeriesPoint {
t: number;
v: number | null;
}
export interface ChartSeries {
label: string;
points: SeriesPoint[];
}
/** Merge multiple time-series into a single recharts-friendly array. */
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
const map = new Map<number, Record<string, unknown>>();
for (const s of series) {
for (const p of s.points) {
const existing = map.get(p.t) ?? { time: p.t };
existing[s.label] = p.v;
map.set(p.t, existing);
}
}
return [...map.values()].sort(
(a, b) => (a.time as number) - (b.time as number),
);
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
interface LineSeriesChartProps {
series: ChartSeries[];
height?: number;
}
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
export function LineSeriesChart({
series,
height = 300,
}: LineSeriesChartProps) {
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={mergeSeries(series)}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
color: "hsl(var(--popover-foreground))",
}}
/>
{series.map((s, i) => (
<Line
key={s.label}
type="monotone"
dataKey={s.label}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
dot={false}
strokeWidth={2}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { LineSeriesChart } from "../LineSeriesChart";
import type { ChartSeries } from "../LineSeriesChart";
describe("LineSeriesChart", () => {
it("renders without crashing with series data", () => {
const series: ChartSeries[] = [
{
label: "cpu",
points: [
{ t: 1000, v: 0.5 },
{ t: 2000, v: 0.8 },
],
},
];
const { container } = render(<LineSeriesChart series={series} />);
// ResponsiveContainer renders a wrapper div even in jsdom
expect(container.firstChild).not.toBeNull();
});
it("renders without crashing with empty series", () => {
const { container } = render(<LineSeriesChart series={[]} />);
expect(container.firstChild).not.toBeNull();
});
it("renders with custom height", () => {
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
const { container } = render(
<LineSeriesChart series={series} height={200} />,
);
expect(container.firstChild).not.toBeNull();
});
});
@@ -15,6 +15,7 @@ describe("service registry", () => {
"jellyfin",
"nextcloud",
"prometheus",
"qbittorrent",
"ssh_tasks",
]);
});
+37
View File
@@ -7,6 +7,9 @@ import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget";
import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget";
import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget";
import type {
@@ -157,6 +160,40 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
],
},
qbittorrent: {
serviceType: "qbittorrent",
name: "qBittorrent",
description: "Torrent client activity, speeds, and item counts.",
widgets: [
{
kind: "totals",
name: "Totals",
description: "Count of all listed torrents, broken down by state.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentTotalsWidget,
},
{
kind: "active",
name: "Active torrents",
description: "Torrents currently downloading or uploading.",
refreshIntervalMs: 15_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentActiveTorrentsWidget,
},
{
kind: "speed",
name: "Speed chart",
description: "Live download/upload speed over a short window.",
refreshIntervalMs: 5_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentSpeedWidget,
},
],
},
jellyfin: {
serviceType: "jellyfin",
name: "Jellyfin",
+3 -81
View File
@@ -1,17 +1,10 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart";
import type { ChartSeries } from "../components/LineSeriesChart";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface Props {
widget: WidgetInstance;
@@ -19,46 +12,6 @@ interface Props {
description?: string;
}
interface SeriesPoint {
t: number;
v: number | null;
}
interface ChartSeries {
label: string;
points: SeriesPoint[];
}
/** Merge multiple time-series into a single recharts-friendly array. */
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
const map = new Map<number, Record<string, unknown>>();
for (const s of series) {
for (const p of s.points) {
const existing = map.get(p.t) ?? { time: p.t };
existing[s.label] = p.v;
map.set(p.t, existing);
}
}
return [...map.values()].sort(
(a, b) => (a.time as number) - (b.time as number),
);
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
export function PrometheusChartWidget({
widget,
refreshIntervalMs,
@@ -76,38 +29,7 @@ export function PrometheusChartWidget({
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={mergeSeries(series)}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
color: "hsl(var(--popover-foreground))",
}}
/>
{series.map((s, i) => (
<Line
key={s.label}
type="monotone"
dataKey={s.label}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
dot={false}
strokeWidth={2}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
<LineSeriesChart series={series} />
) : (
<Alert>
<AlertDescription>
@@ -0,0 +1,76 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface ActiveTorrent {
name: string | null;
state: string | null;
size: number | null;
progress: number | null;
dl_speed: number | null;
up_speed: number | null;
}
function formatSpeed(bytesPerSec: number | null): string {
if (bytesPerSec === null || bytesPerSec <= 0) return "—";
const mb = bytesPerSec / 1_000_000;
if (mb >= 1) return `${mb.toFixed(1)} MB/s`;
return `${(bytesPerSec / 1000).toFixed(0)} KB/s`;
}
export function QbittorrentActiveTorrentsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const payload = data?.data as { torrents?: ActiveTorrent[] } | undefined;
const torrents = payload?.torrents ?? [];
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : torrents.length > 0 ? (
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
{torrents.map((t, i) => (
<li
key={`${t.name}-${i}`}
className="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-sm"
>
<span className="truncate">{t.name ?? "Unknown"}</span>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatSpeed(t.dl_speed)} {formatSpeed(t.up_speed)}
</span>
<Badge
variant={t.state === "downloading" ? "default" : "secondary"}
>
{t.state ?? "?"}
</Badge>
</div>
</li>
))}
</ul>
) : (
<div className="text-xs text-muted-foreground">No active torrents</div>
)}
</SectionCard>
);
}
@@ -0,0 +1,40 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { LineSeriesChart } from "../components/LineSeriesChart";
import type { ChartSeries } from "../components/LineSeriesChart";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function QbittorrentSpeedWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const series = data?.data?.series as ChartSeries[] | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-[220px] w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<LineSeriesChart series={series} height={220} />
) : (
<Alert>
<AlertDescription>No speed data yet</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,51 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function QbittorrentTotalsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const payload = data?.data as
| { total?: number; by_state?: Record<string, number> }
| undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-20 w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : payload ? (
<div className="space-y-3">
<div>
<div className="text-3xl font-semibold">{payload.total ?? 0}</div>
<div className="text-xs text-muted-foreground">Total torrents</div>
</div>
{payload.by_state && Object.keys(payload.by_state).length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{Object.entries(payload.by_state).map(([state, count]) => (
<Badge key={state} variant="secondary">
{state}: {count}
</Badge>
))}
</div>
) : null}
</div>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,103 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QbittorrentActiveTorrentsWidget } from "../QbittorrentActiveTorrentsWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "active",
title: "Active Torrents",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("QbittorrentActiveTorrentsWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
});
it("renders active torrent rows", () => {
mockData({
torrents: [
{
name: "Movie.mkv",
state: "downloading",
size: 1000,
progress: 0.5,
dl_speed: 500000,
up_speed: 1000,
},
{
name: "Show.mkv",
state: "uploading",
size: 2000,
progress: 1.0,
dl_speed: 0,
up_speed: 50000,
},
],
});
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(screen.getByText("Movie.mkv")).toBeInTheDocument();
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
expect(screen.getByText("downloading")).toBeInTheDocument();
expect(screen.getByText("uploading")).toBeInTheDocument();
});
it("shows empty state when no active torrents", () => {
mockData({ torrents: [] });
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(screen.getByText(/No active torrents/i)).toBeInTheDocument();
});
it("shows error alert on error", () => {
mockData(null, "qBittorrent fetch failed");
render(
<QbittorrentActiveTorrentsWidget
widget={widget}
refreshIntervalMs={15000}
/>,
);
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QbittorrentSpeedWidget } from "../QbittorrentSpeedWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "speed",
title: "Speed Chart",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("QbittorrentSpeedWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
});
it("renders chart with series data", () => {
mockData({
series: [
{ label: "download", points: [{ t: 1000, v: 500000 }] },
{ label: "upload", points: [{ t: 1000, v: 100000 }] },
],
});
const { container } = render(
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
);
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
expect(container.firstChild).not.toBeNull();
});
it("shows empty state when no series", () => {
mockData({ series: [] });
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
expect(screen.getByText(/No speed data yet/i)).toBeInTheDocument();
});
it("shows error alert on error", () => {
mockData(null, "qBittorrent fetch failed");
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QbittorrentTotalsWidget } from "../QbittorrentTotalsWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "totals",
title: "Torrent Totals",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("QbittorrentTotalsWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
});
it("renders total count and state badges", () => {
mockData({
total: 4,
by_state: { downloading: 1, uploading: 1, pausedDL: 2 },
});
render(
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("downloading: 1")).toBeInTheDocument();
expect(screen.getByText("pausedDL: 2")).toBeInTheDocument();
});
it("shows error alert on error", () => {
mockData(null, "qBittorrent fetch failed");
render(
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
});
});
+4
View File
@@ -4,6 +4,10 @@ export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { QbittorrentActiveTorrentsWidget } from "./QbittorrentActiveTorrentsWidget";
export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget";
export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget";
export { SshTaskWidget } from "./SshTaskWidget";
export { StaticWidget } from "./StaticWidget";