Compare commits

...

2 Commits

Author SHA1 Message Date
Developer fe90feb1b7 fix: use saved SSH keys for task runners 2026-07-14 17:20:27 +00:00
Developer 230b4b8533 fix: include all active torrent states 2026-07-14 17:20:27 +00:00
9 changed files with 224 additions and 106 deletions
@@ -117,7 +117,7 @@ DEFINITION = ServiceDefinition(
widget_kind(
kind="active",
name="Active torrents",
description="Torrents currently downloading or uploading.",
description="All active download/upload work, including queued and stalled transfers.",
model_cls=QbittorrentWidgetConfig,
default_config={},
refresh_interval_ms=15_000,
@@ -411,9 +411,10 @@ def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> Qbittorren
_QBITTORRENT_DOWNLOAD_STATES = frozenset(
{"downloading", "forceddl", "stalleddl", "metadl", "allocating"}
{"downloading", "forceddl", "stalleddl", "queueddl", "metadl", "forcedmetadl", "allocating", "checkingdl"}
)
_QBITTORRENT_UPLOAD_STATES = frozenset({"uploading", "forcedup", "stalledup"})
_QBITTORRENT_UPLOAD_STATES = frozenset({"uploading", "forcedup", "stalledup", "queuedup", "checkingup"})
_QBITTORRENT_OTHER_ACTIVE_STATES = frozenset({"checkingresumedata", "moving"})
def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None:
@@ -430,6 +431,11 @@ def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None:
return None
def _qbit_torrent_is_active(torrent: dict[str, Any]) -> bool:
state = str(torrent.get("state") or "").lower()
return bool(_qbit_torrent_direction(torrent)) or state in _QBITTORRENT_OTHER_ACTIVE_STATES
class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets."""
@@ -492,9 +498,9 @@ class QbittorrentWidgetSource:
if widget_kind == "active":
active = []
for torrent in torrents.values():
direction = _qbit_torrent_direction(torrent)
if not direction:
if not _qbit_torrent_is_active(torrent):
continue
direction = _qbit_torrent_direction(torrent)
active.append(
{
"name": torrent.get("name"),
+5 -5
View File
@@ -1128,12 +1128,12 @@ async def test_qbittorrent_totals_counts_all_torrents():
assert result["by_state"]["uploading"] == 1
assert result["by_state"]["queuedDL"] == 1
assert result["by_state"]["pausedDL"] == 1
assert result["by_direction"] == {"downloading": 2, "uploading": 2}
assert result["by_direction"] == {"downloading": 3, "uploading": 2}
@pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only():
"""Active kind returns only downloading/uploading torrents (Q3)."""
"""Active kind returns all active download/upload states, including queued work."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
@@ -1149,14 +1149,14 @@ async def test_qbittorrent_active_filters_dl_ul_only():
result = await adapter.fetch(service, "active", {})
active = result["torrents"]
assert len(active) == 4
assert len(active) == 5
names = [t["name"] for t in active]
assert "Movie.mkv" in names
assert "Show.mkv" in names
assert "Forced download" in names
assert "Stalled upload" in names
# Queued and paused are excluded
assert "Queued" not in names
assert "Queued" in names
# Paused torrents remain excluded, but queued transfer work is visible.
assert "Paused" not in names
+2 -1
View File
@@ -210,7 +210,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
{
kind: "active",
name: "Active torrents",
description: "Torrents currently downloading or uploading.",
description:
"All active download/upload work, including queued and stalled transfers.",
refreshIntervalMs: 15_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
+35 -2
View File
@@ -34,6 +34,7 @@ import {
useTestServiceInstance,
} from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices";
import { useSSHKeys } from "../hooks/useSettings";
import {
useDashboards,
useDeleteDashboard,
@@ -45,6 +46,7 @@ import type {
ServiceInstanceInput,
ServiceTestResult,
ServiceTypeInfo,
SSHKey,
} from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
@@ -92,10 +94,12 @@ function ServiceConfigFields({
type,
config,
onChange,
sshKeys,
}: {
type: ServiceTypeInfo;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
sshKeys: SSHKey[];
}) {
const properties =
(
@@ -109,6 +113,7 @@ function ServiceConfigFields({
// Multi-line resizable textarea for fields that hold complex values (opt-in
// via `format: "textarea"`, or well-known multi-line keys).
const TEXTAREA_KEYS = new Set(["notes", "command"]);
const noSSHKey = "__no_ssh_key__";
return (
<div className="flex flex-col gap-3">
{Object.entries(properties).map(([key, schema]) => {
@@ -118,11 +123,37 @@ function ServiceConfigFields({
return (
<Field
key={key}
label={key}
label={
type.service_type === "ssh_tasks" && key === "ssh_key_id"
? "SSH key"
: key
}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
{isTextarea ? (
{type.service_type === "ssh_tasks" && key === "ssh_key_id" ? (
<Select
value={String(config[key] ?? "") || noSSHKey}
onValueChange={(value) =>
onChange({
...config,
[key]: value === noSSHKey ? "" : value,
})
}
>
<SelectTrigger id={`cfg-${key}`} className="w-full">
<SelectValue placeholder="Select an SSH key" />
</SelectTrigger>
<SelectContent>
<SelectItem value={noSSHKey}>No key selected</SelectItem>
{sshKeys.map((sshKey) => (
<SelectItem key={sshKey.id} value={sshKey.id}>
{sshKey.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : isTextarea ? (
<Textarea
id={`cfg-${key}`}
rows={6}
@@ -195,6 +226,7 @@ export function CreateServiceDialog({
onClose: () => void;
}) {
const { data: types = [] } = useServiceTypes();
const { data: sshKeys = [] } = useSSHKeys();
const saveService = useSaveServiceInstance();
const testService = useTestServiceInstance();
const [draft, setDraft] = useState<CreateDraft | null>(null);
@@ -306,6 +338,7 @@ export function CreateServiceDialog({
<ServiceConfigFields
type={selectedType}
config={draft.config}
sshKeys={sshKeys}
onChange={(config) => setDraft({ ...draft, config })}
/>
) : null}
+117 -84
View File
@@ -1390,75 +1390,75 @@ function ServicesAdminCard({
return (
<>
<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>
</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 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>
</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>
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
@@ -1477,6 +1477,7 @@ function ServiceConfigEditor({
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const testService = useTestServiceInstance();
const { data: sshKeys = [] } = useSSHKeys();
const [name, setName] = useState(instance.name);
const [enabled, setEnabled] = useState(instance.enabled);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
@@ -1598,25 +1599,57 @@ function ServiceConfigEditor({
return (
<FormField
key={key}
label={key}
label={
instance.service_type === "ssh_tasks" && key === "ssh_key_id"
? "SSH key"
: key
}
htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText={schema.description}
>
<Input
id={`svc-cfg-${instance.id}-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
setDraftConfig({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
{instance.service_type === "ssh_tasks" &&
key === "ssh_key_id" ? (
<Select
value={String(draftConfig[key] ?? "") || NONE}
onValueChange={(value) =>
setDraftConfig({
...draftConfig,
[key]: value === NONE ? "" : value,
})
}
>
<SelectTrigger
id={`svc-cfg-${instance.id}-${key}`}
className="w-full"
>
<SelectValue placeholder="Select an SSH key" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>No key selected</SelectItem>
{sshKeys.map((sshKey) => (
<SelectItem key={sshKey.id} value={sshKey.id}>
{sshKey.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`svc-cfg-${instance.id}-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
setDraftConfig({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
)}
</FormField>
);
})}
@@ -22,7 +22,7 @@ const saveServiceMutate = vi.fn().mockResolvedValue({});
// tab, so stub them out to keep the render focused on the Services editor.
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
useSSHKeys: () => ({ data: [] }),
useSSHKeys: () => ({ data: [{ id: "key-1", name: "Production key" }] }),
useSaveMonitoringMachine: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteMonitoringMachine: () => ({ mutate: vi.fn() }),
useTestMonitoringMachineSSH: () => ({
@@ -75,6 +75,22 @@ vi.mock("../../hooks/useServices", () => ({
],
widget_kinds: [],
},
{
service_type: "ssh_tasks",
name: "SSH task runner",
description: "Run saved tasks over SSH",
config_schema: {
type: "object",
properties: {
host: { type: "string" },
port: { type: "integer" },
username: { type: "string" },
ssh_key_id: { type: "string" },
},
},
secret_fields: [],
widget_kinds: [],
},
],
}),
useServiceInstances: () => ({
@@ -156,6 +172,15 @@ describe("Settings > Services editor", () => {
await userEvent.click(screen.getByRole("button", { name: "Add service" }));
expect(screen.getByText("New service")).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "SSH task runner" }),
);
const sshKeySelect = screen.getByRole("combobox", { name: "SSH key" });
await userEvent.click(sshKeySelect);
expect(
screen.getByRole("option", { name: "Production key" }),
).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));
@@ -40,19 +40,30 @@ function formatProgress(progress: number | null): string {
return `${Math.round(progress * 100)}% complete`;
}
function formatState(state: string | null, direction?: ActiveTorrent["direction"]): string {
function formatState(
state: string | null,
direction?: ActiveTorrent["direction"],
): string {
const labels: Record<string, string> = {
downloading: "Downloading",
forcedDL: "Downloading",
stalledDL: "Download stalled",
queuedDL: "Queued download",
metaDL: "Downloading metadata",
forcedMetaDL: "Downloading metadata",
checkingDL: "Checking download",
allocating: "Allocating",
uploading: "Uploading",
forcedUP: "Uploading",
stalledUP: "Upload stalled",
queuedUP: "Queued upload",
checkingUP: "Checking upload",
checkingResumeData: "Checking resume data",
moving: "Moving",
};
if (state && labels[state]) return labels[state];
if (direction) return direction === "downloading" ? "Downloading" : "Uploading";
if (direction)
return direction === "downloading" ? "Downloading" : "Uploading";
return state || "Unknown state";
}
@@ -91,7 +102,8 @@ export function QbittorrentActiveTorrentsWidget({
{torrent.name ?? "Unknown torrent"}
</p>
<p className="text-xs text-muted-foreground">
{formatSize(torrent.size)} · {formatProgress(torrent.progress)}
{formatSize(torrent.size)} ·{" "}
{formatProgress(torrent.progress)}
</p>
</div>
<Badge
@@ -103,7 +115,8 @@ export function QbittorrentActiveTorrentsWidget({
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatSpeed(torrent.dl_speed)} · {formatSpeed(torrent.up_speed)}
{formatSpeed(torrent.dl_speed)} · {" "}
{formatSpeed(torrent.up_speed)}
</div>
</li>
);
@@ -50,7 +50,9 @@ 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())
state
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase())
);
}
@@ -76,7 +78,8 @@ export function QbittorrentTotalsWidget({
payload?.by_direction?.downloading ??
fallbackDirectionCount(byState, DOWNLOAD_STATES);
const uploading =
payload?.by_direction?.uploading ?? fallbackDirectionCount(byState, UPLOAD_STATES);
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)),
@@ -95,7 +98,9 @@ export function QbittorrentTotalsWidget({
<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 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>
@@ -126,7 +131,9 @@ export function QbittorrentTotalsWidget({
)}
</div>
) : (
<div className="text-xs text-muted-foreground">No torrent data available.</div>
<div className="text-xs text-muted-foreground">
No torrent data available.
</div>
)}
</SectionCard>
);