fix: show active qBittorrent transfers
This commit is contained in:
@@ -410,6 +410,26 @@ def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> Qbittorren
|
|||||||
return QbittorrentClient(base_url, username, password, timeout=timeout)
|
return QbittorrentClient(base_url, username, password, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
_QBITTORRENT_DOWNLOAD_STATES = frozenset(
|
||||||
|
{"downloading", "forceddl", "stalleddl", "metadl", "allocating"}
|
||||||
|
)
|
||||||
|
_QBITTORRENT_UPLOAD_STATES = frozenset({"uploading", "forcedup", "stalledup"})
|
||||||
|
|
||||||
|
|
||||||
|
def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None:
|
||||||
|
"""Return the transfer direction for active qBittorrent states or speeds."""
|
||||||
|
state = str(torrent.get("state") or "").lower()
|
||||||
|
if state in _QBITTORRENT_DOWNLOAD_STATES:
|
||||||
|
return "downloading"
|
||||||
|
if state in _QBITTORRENT_UPLOAD_STATES:
|
||||||
|
return "uploading"
|
||||||
|
if _safe_int(torrent.get("dlspeed")) > 0:
|
||||||
|
return "downloading"
|
||||||
|
if _safe_int(torrent.get("upspeed")) > 0:
|
||||||
|
return "uploading"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class QbittorrentWidgetSource:
|
class QbittorrentWidgetSource:
|
||||||
"""Fetch qBittorrent data for totals, active, and speed widgets."""
|
"""Fetch qBittorrent data for totals, active, and speed widgets."""
|
||||||
|
|
||||||
@@ -456,24 +476,36 @@ class QbittorrentWidgetSource:
|
|||||||
|
|
||||||
if widget_kind == "totals":
|
if widget_kind == "totals":
|
||||||
by_state: dict[str, int] = {}
|
by_state: dict[str, int] = {}
|
||||||
for t in torrents.values():
|
by_direction = {"downloading": 0, "uploading": 0}
|
||||||
state = str(t.get("state", "unknown"))
|
for torrent in torrents.values():
|
||||||
|
state = str(torrent.get("state") or "unknown")
|
||||||
by_state[state] = by_state.get(state, 0) + 1
|
by_state[state] = by_state.get(state, 0) + 1
|
||||||
return {"total": len(torrents), "by_state": by_state}
|
direction = _qbit_torrent_direction(torrent)
|
||||||
|
if direction:
|
||||||
|
by_direction[direction] += 1
|
||||||
|
return {
|
||||||
|
"total": len(torrents),
|
||||||
|
"by_state": by_state,
|
||||||
|
"by_direction": by_direction,
|
||||||
|
}
|
||||||
|
|
||||||
if widget_kind == "active":
|
if widget_kind == "active":
|
||||||
active = [
|
active = []
|
||||||
{
|
for torrent in torrents.values():
|
||||||
"name": t.get("name"),
|
direction = _qbit_torrent_direction(torrent)
|
||||||
"state": t.get("state"),
|
if not direction:
|
||||||
"size": t.get("size"),
|
continue
|
||||||
"progress": t.get("progress"),
|
active.append(
|
||||||
"dl_speed": t.get("dlspeed"),
|
{
|
||||||
"up_speed": t.get("upspeed"),
|
"name": torrent.get("name"),
|
||||||
}
|
"state": torrent.get("state"),
|
||||||
for t in torrents.values()
|
"direction": direction,
|
||||||
if str(t.get("state", "")) in {"downloading", "uploading"}
|
"size": torrent.get("size"),
|
||||||
]
|
"progress": torrent.get("progress"),
|
||||||
|
"dl_speed": torrent.get("dlspeed"),
|
||||||
|
"up_speed": torrent.get("upspeed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
return {"torrents": active}
|
return {"torrents": active}
|
||||||
|
|
||||||
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
||||||
|
|||||||
@@ -1086,6 +1086,22 @@ def _fake_qbit_maindata():
|
|||||||
"dlspeed": 0,
|
"dlspeed": 0,
|
||||||
"upspeed": 0,
|
"upspeed": 0,
|
||||||
},
|
},
|
||||||
|
"h5": {
|
||||||
|
"name": "Forced download",
|
||||||
|
"state": "forcedDL",
|
||||||
|
"size": 5000,
|
||||||
|
"progress": 0.4,
|
||||||
|
"dlspeed": 0,
|
||||||
|
"upspeed": 0,
|
||||||
|
},
|
||||||
|
"h6": {
|
||||||
|
"name": "Stalled upload",
|
||||||
|
"state": "stalledUP",
|
||||||
|
"size": 6000,
|
||||||
|
"progress": 1.0,
|
||||||
|
"dlspeed": 0,
|
||||||
|
"upspeed": 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,11 +1123,12 @@ async def test_qbittorrent_totals_counts_all_torrents():
|
|||||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||||
result = await adapter.fetch(service, "totals", {})
|
result = await adapter.fetch(service, "totals", {})
|
||||||
|
|
||||||
assert result["total"] == 4
|
assert result["total"] == 6
|
||||||
assert result["by_state"]["downloading"] == 1
|
assert result["by_state"]["downloading"] == 1
|
||||||
assert result["by_state"]["uploading"] == 1
|
assert result["by_state"]["uploading"] == 1
|
||||||
assert result["by_state"]["queuedDL"] == 1
|
assert result["by_state"]["queuedDL"] == 1
|
||||||
assert result["by_state"]["pausedDL"] == 1
|
assert result["by_state"]["pausedDL"] == 1
|
||||||
|
assert result["by_direction"] == {"downloading": 2, "uploading": 2}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1132,10 +1149,12 @@ async def test_qbittorrent_active_filters_dl_ul_only():
|
|||||||
result = await adapter.fetch(service, "active", {})
|
result = await adapter.fetch(service, "active", {})
|
||||||
|
|
||||||
active = result["torrents"]
|
active = result["torrents"]
|
||||||
assert len(active) == 2
|
assert len(active) == 4
|
||||||
names = [t["name"] for t in active]
|
names = [t["name"] for t in active]
|
||||||
assert "Movie.mkv" in names
|
assert "Movie.mkv" in names
|
||||||
assert "Show.mkv" in names
|
assert "Show.mkv" in names
|
||||||
|
assert "Forced download" in names
|
||||||
|
assert "Stalled upload" in names
|
||||||
# Queued and paused are excluded
|
# Queued and paused are excluded
|
||||||
assert "Queued" not in names
|
assert "Queued" not in names
|
||||||
assert "Paused" not in names
|
assert "Paused" not in names
|
||||||
|
|||||||
@@ -286,8 +286,9 @@ values missing an `http://` or `https://` schema with a clear validation error
|
|||||||
registry; every run is recorded in `service_task_runs` as history.
|
registry; every run is recorded in `service_task_runs` as history.
|
||||||
|
|
||||||
Multiple instances per service type are supported. Services are managed from
|
Multiple instances per service type are supported. Services are managed from
|
||||||
**Settings → Services**, which provides a **List** subtab for creating and editing
|
**Settings → Services**, which provides a list view for creating and editing
|
||||||
instances and a **Dashboards** subtab for named dashboard management. Each
|
instances. Named dashboards are managed in their own **Settings → Dashboards**
|
||||||
|
tab. Each
|
||||||
instance retains its operational detail page at `/services/:serviceType/:serviceId`;
|
instance retains its operational detail page at `/services/:serviceType/:serviceId`;
|
||||||
legacy `/services` navigation redirects to Settings.
|
legacy `/services` navigation redirects to Settings.
|
||||||
|
|
||||||
|
|||||||
@@ -467,7 +467,10 @@ function AppInner() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<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
|
<Route
|
||||||
path="/services/:serviceType"
|
path="/services/:serviceType"
|
||||||
element={<ServiceTypePage />}
|
element={<ServiceTypePage />}
|
||||||
@@ -495,7 +498,10 @@ function AppInner() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<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
|
<Route
|
||||||
path="/services/:serviceType"
|
path="/services/:serviceType"
|
||||||
element={<ServiceTypePage />}
|
element={<ServiceTypePage />}
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ function ServiceConfigFields({
|
|||||||
const properties =
|
const properties =
|
||||||
(
|
(
|
||||||
type.config_schema as {
|
type.config_schema as {
|
||||||
properties?: Record<string, { type?: string; description?: string; format?: string }>;
|
properties?: Record<
|
||||||
|
string,
|
||||||
|
{ type?: string; description?: string; format?: string }
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
).properties ?? {};
|
).properties ?? {};
|
||||||
// Multi-line resizable textarea for fields that hold complex values (opt-in
|
// 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">
|
<div className="flex flex-col gap-3">
|
||||||
{Object.entries(properties).map(([key, schema]) => {
|
{Object.entries(properties).map(([key, schema]) => {
|
||||||
const isNumber = schema.type === "integer" || schema.type === "number";
|
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 (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
label={key}
|
label={key}
|
||||||
htmlFor={`cfg-${key}`}
|
htmlFor={`cfg-${key}`}
|
||||||
helper={schema.description}
|
helper={schema.description}
|
||||||
>
|
>
|
||||||
{isTextarea ? (
|
{isTextarea ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`cfg-${key}`}
|
id={`cfg-${key}`}
|
||||||
rows={6}
|
rows={6}
|
||||||
className="resize font-mono text-xs min-h-[120px]"
|
className="resize font-mono text-xs min-h-[120px]"
|
||||||
value={String(config[key] ?? "")}
|
value={String(config[key] ?? "")}
|
||||||
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id={`cfg-${key}`}
|
id={`cfg-${key}`}
|
||||||
type={isNumber ? "number" : "text"}
|
type={isNumber ? "number" : "text"}
|
||||||
value={String(config[key] ?? "")}
|
value={String(config[key] ?? "")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
[key]: isNumber
|
[key]: isNumber
|
||||||
? e.target.value === ""
|
? e.target.value === ""
|
||||||
? undefined
|
? undefined
|
||||||
: Number(e.target.value)
|
: Number(e.target.value)
|
||||||
: e.target.value,
|
: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ const SERVICE_OPTIONS = [
|
|||||||
// maps to this sentinel and converts back to "" at the draft boundary.
|
// maps to this sentinel and converts back to "" at the draft boundary.
|
||||||
const NONE = "__none__";
|
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. */
|
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||||
function FormField({
|
function FormField({
|
||||||
@@ -1018,6 +1023,9 @@ export function Settings() {
|
|||||||
<TabsTrigger key="services" value="services">
|
<TabsTrigger key="services" value="services">
|
||||||
Services
|
Services
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
|
<TabsTrigger key="dashboards" value="dashboards">
|
||||||
|
Dashboards
|
||||||
|
</TabsTrigger>,
|
||||||
<TabsTrigger key="danger" value="danger">
|
<TabsTrigger key="danger" value="danger">
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
@@ -1204,6 +1212,7 @@ export function Settings() {
|
|||||||
{tab === "services" && (
|
{tab === "services" && (
|
||||||
<ServicesAdminCard initialServiceId={initialServiceId} />
|
<ServicesAdminCard initialServiceId={initialServiceId} />
|
||||||
)}
|
)}
|
||||||
|
{tab === "dashboards" && <DashboardManagementCard />}
|
||||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -1354,7 +1363,6 @@ function ServicesAdminCard({
|
|||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const { data: types = [] } = useServiceTypes();
|
const { data: types = [] } = useServiceTypes();
|
||||||
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
|
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
|
||||||
const [serviceSubtab, setServiceSubtab] = useState<"list" | "dashboards">("list");
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const sortedServices = useMemo(
|
const sortedServices = useMemo(
|
||||||
@@ -1381,89 +1389,76 @@ function ServicesAdminCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TabbedCard
|
<div className="flex flex-col gap-4">
|
||||||
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 justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||||
Add service
|
Add service
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{sortedServices.length === 0 ? (
|
{sortedServices.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No service instances configured yet.
|
No service instances configured yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<SelectionRailCard
|
<SelectionRailCard
|
||||||
title="Services"
|
title="Services"
|
||||||
description="Select a service to edit its configuration."
|
description="Select a service to edit its configuration."
|
||||||
minHeight={420}
|
minHeight={420}
|
||||||
>
|
>
|
||||||
{sortedServices.map((svc) => {
|
{sortedServices.map((svc) => {
|
||||||
const active = svc.id === (selectedService?.id ?? "");
|
const active = svc.id === (selectedService?.id ?? "");
|
||||||
const typeName =
|
const typeName =
|
||||||
types.find((t) => t.service_type === svc.service_type)?.name ??
|
types.find((t) => t.service_type === svc.service_type)
|
||||||
svc.service_type;
|
?.name ?? svc.service_type;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={svc.id}
|
key={svc.id}
|
||||||
onClick={() => setSelectedServiceId(svc.id)}
|
onClick={() => setSelectedServiceId(svc.id)}
|
||||||
className={cn(
|
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",
|
"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",
|
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold">{svc.name}</p>
|
<p className="truncate font-semibold">{svc.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<Badge variant={svc.enabled ? "default" : "secondary"}>
|
)}
|
||||||
{svc.enabled ? "on" : "off"}
|
</div>
|
||||||
</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>
|
|
||||||
<CreateServiceDialog
|
<CreateServiceDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
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(
|
render(
|
||||||
<MemoryRouter initialEntries={["/settings?tab=services"]}>
|
<MemoryRouter initialEntries={["/settings?tab=services"]}>
|
||||||
<Settings />
|
<Settings />
|
||||||
@@ -159,7 +159,11 @@ describe("Settings > Services editor", () => {
|
|||||||
await userEvent.keyboard("{Escape}");
|
await userEvent.keyboard("{Escape}");
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));
|
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));
|
||||||
expect(screen.getByRole("heading", { name: "Dashboards" })).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.getByRole("button", { name: "New dashboard" })).toBeInTheDocument();
|
screen.getByRole("heading", { name: "Dashboards" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "New dashboard" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Props {
|
|||||||
interface ActiveTorrent {
|
interface ActiveTorrent {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
|
direction?: "downloading" | "uploading";
|
||||||
size: number | null;
|
size: number | null;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
dl_speed: number | null;
|
dl_speed: number | null;
|
||||||
@@ -27,6 +28,34 @@ function formatSpeed(bytesPerSec: number | null): string {
|
|||||||
return `${(bytesPerSec / 1000).toFixed(0)} KB/s`;
|
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({
|
export function QbittorrentActiveTorrentsWidget({
|
||||||
widget,
|
widget,
|
||||||
refreshIntervalMs,
|
refreshIntervalMs,
|
||||||
@@ -40,36 +69,50 @@ export function QbittorrentActiveTorrentsWidget({
|
|||||||
<SectionCard title={widget.title} description={description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-8 w-full" />
|
<Skeleton className="h-12 w-full" />
|
||||||
<Skeleton className="h-8 w-full" />
|
<Skeleton className="h-12 w-full" />
|
||||||
</div>
|
</div>
|
||||||
) : data?.error ? (
|
) : data?.error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{data.error}</AlertDescription>
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : torrents.length > 0 ? (
|
) : torrents.length > 0 ? (
|
||||||
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
|
<ul className="max-h-96 space-y-1.5 overflow-y-auto">
|
||||||
{torrents.map((t, i) => (
|
{torrents.map((torrent, index) => {
|
||||||
<li
|
const direction = torrent.direction;
|
||||||
key={`${t.name}-${i}`}
|
return (
|
||||||
className="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-sm"
|
<li
|
||||||
>
|
key={`${torrent.name}-${torrent.state}-${index}`}
|
||||||
<span className="truncate">{t.name ?? "Unknown"}</span>
|
className="rounded-md border px-3 py-2 text-sm"
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
>
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex items-start justify-between gap-2">
|
||||||
↓{formatSpeed(t.dl_speed)} ↑{formatSpeed(t.up_speed)}
|
<div className="min-w-0">
|
||||||
</span>
|
<p className="truncate font-medium">
|
||||||
<Badge
|
{torrent.name ?? "Unknown torrent"}
|
||||||
variant={t.state === "downloading" ? "default" : "secondary"}
|
</p>
|
||||||
>
|
<p className="text-xs text-muted-foreground">
|
||||||
{t.state ?? "?"}
|
{formatSize(torrent.size)} · {formatProgress(torrent.progress)}
|
||||||
</Badge>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
<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>
|
</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>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
@@ -11,41 +10,124 @@ interface Props {
|
|||||||
description?: string;
|
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({
|
export function QbittorrentTotalsWidget({
|
||||||
widget,
|
widget,
|
||||||
refreshIntervalMs,
|
refreshIntervalMs,
|
||||||
description,
|
description,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const payload = data?.data as
|
const payload = data?.data as TotalsPayload | undefined;
|
||||||
| { total?: number; by_state?: Record<string, number> }
|
const byState = payload?.by_state ?? {};
|
||||||
| undefined;
|
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 (
|
return (
|
||||||
<SectionCard title={widget.title} description={description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-32 w-full" />
|
||||||
) : data?.error ? (
|
) : data?.error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{data.error}</AlertDescription>
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : payload ? (
|
) : payload ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="text-3xl font-semibold">{payload.total ?? 0}</div>
|
<div className="rounded-md border p-3">
|
||||||
<div className="text-xs text-muted-foreground">Total torrents</div>
|
<div className="text-2xl font-semibold">{payload.total ?? 0}</div>
|
||||||
</div>
|
<div className="text-xs text-muted-foreground">Total torrents</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>
|
</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>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground">No torrent data available.</div>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ describe("QbittorrentActiveTorrentsWidget", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("Movie.mkv")).toBeInTheDocument();
|
expect(screen.getByText("Movie.mkv")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
||||||
expect(screen.getByText("downloading")).toBeInTheDocument();
|
expect(screen.getByText("Downloading")).toBeInTheDocument();
|
||||||
expect(screen.getByText("uploading")).toBeInTheDocument();
|
expect(screen.getByText("Uploading")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty state when no active torrents", () => {
|
it("shows empty state when no active torrents", () => {
|
||||||
@@ -87,7 +87,9 @@ describe("QbittorrentActiveTorrentsWidget", () => {
|
|||||||
refreshIntervalMs={15000}
|
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", () => {
|
it("shows error alert on error", () => {
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ describe("QbittorrentTotalsWidget", () => {
|
|||||||
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("4")).toBeInTheDocument();
|
expect(screen.getByText("4")).toBeInTheDocument();
|
||||||
expect(screen.getByText("downloading: 1")).toBeInTheDocument();
|
expect(screen.getByText("Total torrents")).toBeInTheDocument();
|
||||||
expect(screen.getByText("pausedDL: 2")).toBeInTheDocument();
|
expect(screen.getAllByText("Downloading").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getByText("Paused download")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error alert on error", () => {
|
it("shows error alert on error", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user