Files
manage/frontend/src/widgets/QbittorrentTotalsWidget.tsx
T
2026-07-14 17:20:27 +00:00

141 lines
3.9 KiB
TypeScript

import { Alert, AlertDescription } from "@/components/ui/alert";
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 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 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-32 w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : payload ? (
<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>
<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>
) : (
<div className="text-xs text-muted-foreground">
No torrent data available.
</div>
)}
</SectionCard>
);
}