fix: show active qBittorrent transfers

This commit is contained in:
Developer
2026-07-14 16:07:04 +00:00
parent 70511d97f9
commit a541a4fd16
11 changed files with 376 additions and 187 deletions
+8 -2
View File
@@ -467,7 +467,10 @@ function AppInner() {
<Route path="/" element={<Dashboard />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<Navigate to="/settings?tab=services" replace />} />
<Route
path="/services"
element={<Navigate to="/settings?tab=services" replace />}
/>
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
@@ -495,7 +498,10 @@ function AppInner() {
<Route path="/" element={<Dashboard />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<Navigate to="/settings?tab=services" replace />} />
<Route
path="/services"
element={<Navigate to="/settings?tab=services" replace />}
/>
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
+38 -34
View File
@@ -100,7 +100,10 @@ function ServiceConfigFields({
const properties =
(
type.config_schema as {
properties?: Record<string, { type?: string; description?: string; format?: string }>;
properties?: Record<
string,
{ type?: string; description?: string; format?: string }
>;
}
).properties ?? {};
// Multi-line resizable textarea for fields that hold complex values (opt-in
@@ -110,40 +113,41 @@ function ServiceConfigFields({
<div className="flex flex-col gap-3">
{Object.entries(properties).map(([key, schema]) => {
const isNumber = schema.type === "integer" || schema.type === "number";
const isTextarea = schema.format === "textarea" || TEXTAREA_KEYS.has(key);
const isTextarea =
schema.format === "textarea" || TEXTAREA_KEYS.has(key);
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
{isTextarea ? (
<Textarea
id={`cfg-${key}`}
rows={6}
className="resize font-mono text-xs min-h-[120px]"
value={String(config[key] ?? "")}
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
/>
) : (
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) =>
onChange({
...config,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
)}
</Field>
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
{isTextarea ? (
<Textarea
id={`cfg-${key}`}
rows={6}
className="resize font-mono text-xs min-h-[120px]"
value={String(config[key] ?? "")}
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
/>
) : (
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) =>
onChange({
...config,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
)}
</Field>
);
})}
</div>
+78 -83
View File
@@ -77,7 +77,12 @@ const SERVICE_OPTIONS = [
// maps to this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type SettingsTab = "machines" | "ssh-keys" | "services" | "danger";
type SettingsTab =
| "machines"
| "ssh-keys"
| "services"
| "dashboards"
| "danger";
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({
@@ -1018,6 +1023,9 @@ export function Settings() {
<TabsTrigger key="services" value="services">
Services
</TabsTrigger>,
<TabsTrigger key="dashboards" value="dashboards">
Dashboards
</TabsTrigger>,
<TabsTrigger key="danger" value="danger">
Danger Zone
</TabsTrigger>,
@@ -1204,6 +1212,7 @@ export function Settings() {
{tab === "services" && (
<ServicesAdminCard initialServiceId={initialServiceId} />
)}
{tab === "dashboards" && <DashboardManagementCard />}
{tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
{isMobile ? (
@@ -1354,7 +1363,6 @@ function ServicesAdminCard({
const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes();
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
const [serviceSubtab, setServiceSubtab] = useState<"list" | "dashboards">("list");
const [createOpen, setCreateOpen] = useState(false);
const sortedServices = useMemo(
@@ -1381,89 +1389,76 @@ function ServicesAdminCard({
return (
<>
<TabbedCard
value={serviceSubtab}
onChange={(value) => setServiceSubtab(value as "list" | "dashboards")}
tabs={[
<TabsTrigger key="list" value="list">List</TabsTrigger>,
<TabsTrigger key="dashboards" value="dashboards">Dashboards</TabsTrigger>,
]}
contentSx={{}}
>
{serviceSubtab === "list" && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button variant="outline" onClick={() => setCreateOpen(true)}>
Add service
</Button>
</div>
{sortedServices.length === 0 ? (
<p className="text-sm text-muted-foreground">
No service instances configured yet.
</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="Services"
description="Select a service to edit its configuration."
minHeight={420}
>
{sortedServices.map((svc) => {
const active = svc.id === (selectedService?.id ?? "");
const typeName =
types.find((t) => t.service_type === svc.service_type)?.name ??
svc.service_type;
return (
<div
key={svc.id}
onClick={() => setSelectedServiceId(svc.id)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">{svc.name}</p>
<p className="text-xs text-muted-foreground">
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
</p>
<Button variant="outline" onClick={() => setCreateOpen(true)}>
Add service
</Button>
</div>
{sortedServices.length === 0 ? (
<p className="text-sm text-muted-foreground">
No service instances configured yet.
</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="Services"
description="Select a service to edit its configuration."
minHeight={420}
>
{sortedServices.map((svc) => {
const active = svc.id === (selectedService?.id ?? "");
const typeName =
types.find((t) => t.service_type === svc.service_type)
?.name ?? svc.service_type;
return (
<div
key={svc.id}
onClick={() => setSelectedServiceId(svc.id)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">{svc.name}</p>
<p className="text-xs text-muted-foreground">
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
</p>
</div>
<Badge variant={svc.enabled ? "default" : "secondary"}>
{svc.enabled ? "on" : "off"}
</Badge>
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedService?.name ?? "No service selected"}
description={
selectedTypeInfo?.description ??
"Select a service on the left to edit its configuration."
}
>
{selectedService ? (
<ServiceConfigEditor
// Remount on service switch so useState initializers (name, config,
// secrets) re-run for the new instance. Without this key, switching
// services in the rail keeps the previous service's editable state
// and a save writes stale name/config onto the new service's row.
key={selectedService.id}
instance={selectedService}
typeInfo={selectedTypeInfo}
/>
) : (
<p className="text-sm text-muted-foreground">
Select a service on the left.
</p>
)}
</SectionCard>
</div>
<Badge variant={svc.enabled ? "default" : "secondary"}>
{svc.enabled ? "on" : "off"}
</Badge>
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedService?.name ?? "No service selected"}
description={
selectedTypeInfo?.description ??
"Select a service on the left to edit its configuration."
}
>
{selectedService ? (
<ServiceConfigEditor
// Remount on service switch so useState initializers (name, config,
// secrets) re-run for the new instance. Without this key, switching
// services in the rail keeps the previous service's editable state
// and a save writes stale name/config onto the new service's row.
key={selectedService.id}
instance={selectedService}
typeInfo={selectedTypeInfo}
/>
) : (
<p className="text-sm text-muted-foreground">
Select a service on the left.
</p>
)}
</SectionCard>
</div>
)}
</div>
)}
{serviceSubtab === "dashboards" && <DashboardManagementCard />}
</TabbedCard>
)}
</div>
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
@@ -147,7 +147,7 @@ describe("Settings > Services editor", () => {
});
});
it("offers service creation and dashboard management as Services subtabs", async () => {
it("offers service creation and a separate Settings dashboards tab", async () => {
render(
<MemoryRouter initialEntries={["/settings?tab=services"]}>
<Settings />
@@ -159,7 +159,11 @@ describe("Settings > Services editor", () => {
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));
expect(screen.getByRole("heading", { name: "Dashboards" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "New dashboard" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Dashboards" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "New dashboard" }),
).toBeInTheDocument();
});
});
@@ -14,6 +14,7 @@ interface Props {
interface ActiveTorrent {
name: string | null;
state: string | null;
direction?: "downloading" | "uploading";
size: number | null;
progress: number | null;
dl_speed: number | null;
@@ -27,6 +28,34 @@ function formatSpeed(bytesPerSec: number | null): string {
return `${(bytesPerSec / 1000).toFixed(0)} KB/s`;
}
function formatSize(bytes: number | null): string {
if (bytes === null || bytes < 0) return "Size unknown";
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${(bytes / 1_000).toFixed(0)} KB`;
}
function formatProgress(progress: number | null): string {
if (progress === null) return "Progress unknown";
return `${Math.round(progress * 100)}% complete`;
}
function formatState(state: string | null, direction?: ActiveTorrent["direction"]): string {
const labels: Record<string, string> = {
downloading: "Downloading",
forcedDL: "Downloading",
stalledDL: "Download stalled",
metaDL: "Downloading metadata",
allocating: "Allocating",
uploading: "Uploading",
forcedUP: "Uploading",
stalledUP: "Upload stalled",
};
if (state && labels[state]) return labels[state];
if (direction) return direction === "downloading" ? "Downloading" : "Uploading";
return state || "Unknown state";
}
export function QbittorrentActiveTorrentsWidget({
widget,
refreshIntervalMs,
@@ -40,36 +69,50 @@ export function QbittorrentActiveTorrentsWidget({
<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" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 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 className="max-h-96 space-y-1.5 overflow-y-auto">
{torrents.map((torrent, index) => {
const direction = torrent.direction;
return (
<li
key={`${torrent.name}-${torrent.state}-${index}`}
className="rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">
{torrent.name ?? "Unknown torrent"}
</p>
<p className="text-xs text-muted-foreground">
{formatSize(torrent.size)} · {formatProgress(torrent.progress)}
</p>
</div>
<Badge
variant={
direction === "downloading" ? "default" : "secondary"
}
>
{formatState(torrent.state, direction)}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatSpeed(torrent.dl_speed)} · {formatSpeed(torrent.up_speed)}
</div>
</li>
);
})}
</ul>
) : (
<div className="text-xs text-muted-foreground">No active torrents</div>
<div className="text-xs text-muted-foreground">
No torrents are currently downloading or uploading.
</div>
)}
</SectionCard>
);
+101 -19
View File
@@ -1,5 +1,4 @@
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";
@@ -11,41 +10,124 @@ interface Props {
description?: string;
}
interface TotalsPayload {
total?: number;
by_state?: Record<string, number>;
by_direction?: {
downloading?: number;
uploading?: number;
};
}
const STATE_LABELS: Record<string, string> = {
downloading: "Downloading",
forcedDL: "Downloading",
stalledDL: "Download stalled",
queuedDL: "Queued download",
metaDL: "Downloading metadata",
allocating: "Allocating",
checkingDL: "Checking download",
uploading: "Uploading",
forcedUP: "Uploading",
stalledUP: "Upload stalled",
queuedUP: "Queued upload",
checkingUP: "Checking upload",
pausedDL: "Paused download",
pausedUP: "Paused upload",
moving: "Moving",
unknown: "Unknown state",
};
const DOWNLOAD_STATES = new Set([
"downloading",
"forcedDL",
"stalledDL",
"metaDL",
"allocating",
]);
const UPLOAD_STATES = new Set(["uploading", "forcedUP", "stalledUP"]);
function stateLabel(state: string): string {
return (
STATE_LABELS[state] ??
state.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase())
);
}
function fallbackDirectionCount(
byState: Record<string, number> | undefined,
states: Set<string>,
): number {
return Object.entries(byState ?? {}).reduce(
(total, [state, count]) => total + (states.has(state) ? count : 0),
0,
);
}
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;
const payload = data?.data as TotalsPayload | undefined;
const byState = payload?.by_state ?? {};
const downloading =
payload?.by_direction?.downloading ??
fallbackDirectionCount(byState, DOWNLOAD_STATES);
const uploading =
payload?.by_direction?.uploading ?? fallbackDirectionCount(byState, UPLOAD_STATES);
const stateEntries = Object.entries(byState).sort(
([stateA, countA], [stateB, countB]) =>
countB - countA || stateLabel(stateA).localeCompare(stateLabel(stateB)),
);
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 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 className="space-y-4">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-md border p-3">
<div className="text-2xl font-semibold">{payload.total ?? 0}</div>
<div className="text-xs text-muted-foreground">Total torrents</div>
</div>
) : null}
<div className="rounded-md border p-3">
<div className="text-2xl font-semibold">{downloading}</div>
<div className="text-xs text-muted-foreground">Downloading</div>
</div>
<div className="rounded-md border p-3">
<div className="text-2xl font-semibold">{uploading}</div>
<div className="text-xs text-muted-foreground">Uploading</div>
</div>
</div>
{stateEntries.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-muted-foreground uppercase">
Torrent states
</div>
<div className="grid gap-1.5 sm:grid-cols-2">
{stateEntries.map(([state, count]) => (
<div
key={state}
className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2 text-sm"
>
<span>{stateLabel(state)}</span>
<span className="font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
) : null}
) : (
<div className="text-xs text-muted-foreground">No torrent data available.</div>
)}
</SectionCard>
);
}
@@ -75,8 +75,8 @@ describe("QbittorrentActiveTorrentsWidget", () => {
);
expect(screen.getByText("Movie.mkv")).toBeInTheDocument();
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
expect(screen.getByText("downloading")).toBeInTheDocument();
expect(screen.getByText("uploading")).toBeInTheDocument();
expect(screen.getByText("Downloading")).toBeInTheDocument();
expect(screen.getByText("Uploading")).toBeInTheDocument();
});
it("shows empty state when no active torrents", () => {
@@ -87,7 +87,9 @@ describe("QbittorrentActiveTorrentsWidget", () => {
refreshIntervalMs={15000}
/>,
);
expect(screen.getByText(/No active torrents/i)).toBeInTheDocument();
expect(
screen.getByText(/No torrents are currently downloading or uploading/i),
).toBeInTheDocument();
});
it("shows error alert on error", () => {
@@ -52,8 +52,9 @@ describe("QbittorrentTotalsWidget", () => {
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("downloading: 1")).toBeInTheDocument();
expect(screen.getByText("pausedDL: 2")).toBeInTheDocument();
expect(screen.getByText("Total torrents")).toBeInTheDocument();
expect(screen.getAllByText("Downloading").length).toBeGreaterThanOrEqual(2);
expect(screen.getByText("Paused download")).toBeInTheDocument();
});
it("shows error alert on error", () => {