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( widget_kind(
kind="active", kind="active",
name="Active torrents", name="Active torrents",
description="Torrents currently downloading or uploading.", description="All active download/upload work, including queued and stalled transfers.",
model_cls=QbittorrentWidgetConfig, model_cls=QbittorrentWidgetConfig,
default_config={}, default_config={},
refresh_interval_ms=15_000, 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( _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: 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 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: class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets.""" """Fetch qBittorrent data for totals, active, and speed widgets."""
@@ -492,9 +498,9 @@ class QbittorrentWidgetSource:
if widget_kind == "active": if widget_kind == "active":
active = [] active = []
for torrent in torrents.values(): for torrent in torrents.values():
direction = _qbit_torrent_direction(torrent) if not _qbit_torrent_is_active(torrent):
if not direction:
continue continue
direction = _qbit_torrent_direction(torrent)
active.append( active.append(
{ {
"name": torrent.get("name"), "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"]["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} assert result["by_direction"] == {"downloading": 3, "uploading": 2}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only(): 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 from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource() adapter = QbittorrentWidgetSource()
@@ -1149,14 +1149,14 @@ 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) == 4 assert len(active) == 5
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 "Forced download" in names
assert "Stalled upload" in names assert "Stalled upload" in names
# Queued and paused are excluded assert "Queued" in names
assert "Queued" not in names # Paused torrents remain excluded, but queued transfer work is visible.
assert "Paused" not in names assert "Paused" not in names
+2 -1
View File
@@ -210,7 +210,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
{ {
kind: "active", kind: "active",
name: "Active torrents", name: "Active torrents",
description: "Torrents currently downloading or uploading.", description:
"All active download/upload work, including queued and stalled transfers.",
refreshIntervalMs: 15_000, refreshIntervalMs: 15_000,
defaultConfig: {}, defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] }, configSchema: { type: "object", properties: {}, required: [] },
+35 -2
View File
@@ -34,6 +34,7 @@ import {
useTestServiceInstance, useTestServiceInstance,
} from "../hooks/useServices"; } from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices";
import { useSSHKeys } from "../hooks/useSettings";
import { import {
useDashboards, useDashboards,
useDeleteDashboard, useDeleteDashboard,
@@ -45,6 +46,7 @@ import type {
ServiceInstanceInput, ServiceInstanceInput,
ServiceTestResult, ServiceTestResult,
ServiceTypeInfo, ServiceTypeInfo,
SSHKey,
} from "../types"; } from "../types";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
@@ -92,10 +94,12 @@ function ServiceConfigFields({
type, type,
config, config,
onChange, onChange,
sshKeys,
}: { }: {
type: ServiceTypeInfo; type: ServiceTypeInfo;
config: Record<string, unknown>; config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void; onChange: (config: Record<string, unknown>) => void;
sshKeys: SSHKey[];
}) { }) {
const properties = const properties =
( (
@@ -109,6 +113,7 @@ function ServiceConfigFields({
// Multi-line resizable textarea for fields that hold complex values (opt-in // Multi-line resizable textarea for fields that hold complex values (opt-in
// via `format: "textarea"`, or well-known multi-line keys). // via `format: "textarea"`, or well-known multi-line keys).
const TEXTAREA_KEYS = new Set(["notes", "command"]); const TEXTAREA_KEYS = new Set(["notes", "command"]);
const noSSHKey = "__no_ssh_key__";
return ( return (
<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]) => {
@@ -118,11 +123,37 @@ function ServiceConfigFields({
return ( return (
<Field <Field
key={key} key={key}
label={key} label={
type.service_type === "ssh_tasks" && key === "ssh_key_id"
? "SSH key"
: key
}
htmlFor={`cfg-${key}`} htmlFor={`cfg-${key}`}
helper={schema.description} 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 <Textarea
id={`cfg-${key}`} id={`cfg-${key}`}
rows={6} rows={6}
@@ -195,6 +226,7 @@ export function CreateServiceDialog({
onClose: () => void; onClose: () => void;
}) { }) {
const { data: types = [] } = useServiceTypes(); const { data: types = [] } = useServiceTypes();
const { data: sshKeys = [] } = useSSHKeys();
const saveService = useSaveServiceInstance(); const saveService = useSaveServiceInstance();
const testService = useTestServiceInstance(); const testService = useTestServiceInstance();
const [draft, setDraft] = useState<CreateDraft | null>(null); const [draft, setDraft] = useState<CreateDraft | null>(null);
@@ -306,6 +338,7 @@ export function CreateServiceDialog({
<ServiceConfigFields <ServiceConfigFields
type={selectedType} type={selectedType}
config={draft.config} config={draft.config}
sshKeys={sshKeys}
onChange={(config) => setDraft({ ...draft, config })} onChange={(config) => setDraft({ ...draft, config })}
/> />
) : null} ) : null}
+117 -84
View File
@@ -1390,75 +1390,75 @@ function ServicesAdminCard({
return ( return (
<> <>
<div className="flex flex-col gap-4"> <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) types.find((t) => t.service_type === svc.service_type)
?.name ?? 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> </div>
<Badge variant={svc.enabled ? "default" : "secondary"}> <Badge variant={svc.enabled ? "default" : "secondary"}>
{svc.enabled ? "on" : "off"} {svc.enabled ? "on" : "off"}
</Badge> </Badge>
</div> </div>
); );
})} })}
</SelectionRailCard> </SelectionRailCard>
<SectionCard <SectionCard
title={selectedService?.name ?? "No service selected"} title={selectedService?.name ?? "No service selected"}
description={ description={
selectedTypeInfo?.description ?? selectedTypeInfo?.description ??
"Select a service on the left to edit its configuration." "Select a service on the left to edit its configuration."
} }
> >
{selectedService ? ( {selectedService ? (
<ServiceConfigEditor <ServiceConfigEditor
// Remount on service switch so useState initializers (name, config, // Remount on service switch so useState initializers (name, config,
// secrets) re-run for the new instance. Without this key, switching // secrets) re-run for the new instance. Without this key, switching
// services in the rail keeps the previous service's editable state // services in the rail keeps the previous service's editable state
// and a save writes stale name/config onto the new service's row. // and a save writes stale name/config onto the new service's row.
key={selectedService.id} key={selectedService.id}
instance={selectedService} instance={selectedService}
typeInfo={selectedTypeInfo} typeInfo={selectedTypeInfo}
/> />
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Select a service on the left. Select a service on the left.
</p> </p>
)} )}
</SectionCard> </SectionCard>
</div>
)}
</div> </div>
)}
</div>
<CreateServiceDialog <CreateServiceDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
@@ -1477,6 +1477,7 @@ function ServiceConfigEditor({
const saveService = useSaveServiceInstance(); const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance(); const deleteService = useDeleteServiceInstance();
const testService = useTestServiceInstance(); const testService = useTestServiceInstance();
const { data: sshKeys = [] } = useSSHKeys();
const [name, setName] = useState(instance.name); const [name, setName] = useState(instance.name);
const [enabled, setEnabled] = useState(instance.enabled); const [enabled, setEnabled] = useState(instance.enabled);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({ const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
@@ -1598,25 +1599,57 @@ function ServiceConfigEditor({
return ( return (
<FormField <FormField
key={key} key={key}
label={key} label={
instance.service_type === "ssh_tasks" && key === "ssh_key_id"
? "SSH key"
: key
}
htmlFor={`svc-cfg-${instance.id}-${key}`} htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText={schema.description} helperText={schema.description}
> >
<Input {instance.service_type === "ssh_tasks" &&
id={`svc-cfg-${instance.id}-${key}`} key === "ssh_key_id" ? (
type={isNumber ? "number" : "text"} <Select
value={String(draftConfig[key] ?? "")} value={String(draftConfig[key] ?? "") || NONE}
onChange={(e) => onValueChange={(value) =>
setDraftConfig({ setDraftConfig({
...draftConfig, ...draftConfig,
[key]: isNumber [key]: value === NONE ? "" : value,
? e.target.value === "" })
? undefined }
: Number(e.target.value) >
: e.target.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> </FormField>
); );
})} })}
@@ -22,7 +22,7 @@ const saveServiceMutate = vi.fn().mockResolvedValue({});
// tab, so stub them out to keep the render focused on the Services editor. // tab, so stub them out to keep the render focused on the Services editor.
vi.mock("../../hooks/useSettings", () => ({ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }), useMonitoringSettings: () => ({ data: [] }),
useSSHKeys: () => ({ data: [] }), useSSHKeys: () => ({ data: [{ id: "key-1", name: "Production key" }] }),
useSaveMonitoringMachine: () => ({ mutateAsync: vi.fn(), isPending: false }), useSaveMonitoringMachine: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteMonitoringMachine: () => ({ mutate: vi.fn() }), useDeleteMonitoringMachine: () => ({ mutate: vi.fn() }),
useTestMonitoringMachineSSH: () => ({ useTestMonitoringMachineSSH: () => ({
@@ -75,6 +75,22 @@ vi.mock("../../hooks/useServices", () => ({
], ],
widget_kinds: [], 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: () => ({ useServiceInstances: () => ({
@@ -156,6 +172,15 @@ describe("Settings > Services editor", () => {
await userEvent.click(screen.getByRole("button", { name: "Add service" })); await userEvent.click(screen.getByRole("button", { name: "Add service" }));
expect(screen.getByText("New service")).toBeInTheDocument(); 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.keyboard("{Escape}");
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" })); await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));
@@ -40,19 +40,30 @@ function formatProgress(progress: number | null): string {
return `${Math.round(progress * 100)}% complete`; 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> = { const labels: Record<string, string> = {
downloading: "Downloading", downloading: "Downloading",
forcedDL: "Downloading", forcedDL: "Downloading",
stalledDL: "Download stalled", stalledDL: "Download stalled",
queuedDL: "Queued download",
metaDL: "Downloading metadata", metaDL: "Downloading metadata",
forcedMetaDL: "Downloading metadata",
checkingDL: "Checking download",
allocating: "Allocating", allocating: "Allocating",
uploading: "Uploading", uploading: "Uploading",
forcedUP: "Uploading", forcedUP: "Uploading",
stalledUP: "Upload stalled", stalledUP: "Upload stalled",
queuedUP: "Queued upload",
checkingUP: "Checking upload",
checkingResumeData: "Checking resume data",
moving: "Moving",
}; };
if (state && labels[state]) return labels[state]; 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"; return state || "Unknown state";
} }
@@ -91,7 +102,8 @@ export function QbittorrentActiveTorrentsWidget({
{torrent.name ?? "Unknown torrent"} {torrent.name ?? "Unknown torrent"}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatSize(torrent.size)} · {formatProgress(torrent.progress)} {formatSize(torrent.size)} ·{" "}
{formatProgress(torrent.progress)}
</p> </p>
</div> </div>
<Badge <Badge
@@ -103,7 +115,8 @@ export function QbittorrentActiveTorrentsWidget({
</Badge> </Badge>
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <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> </div>
</li> </li>
); );
@@ -50,7 +50,9 @@ const UPLOAD_STATES = new Set(["uploading", "forcedUP", "stalledUP"]);
function stateLabel(state: string): string { function stateLabel(state: string): string {
return ( return (
STATE_LABELS[state] ?? 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 ?? payload?.by_direction?.downloading ??
fallbackDirectionCount(byState, DOWNLOAD_STATES); fallbackDirectionCount(byState, DOWNLOAD_STATES);
const uploading = const uploading =
payload?.by_direction?.uploading ?? fallbackDirectionCount(byState, UPLOAD_STATES); payload?.by_direction?.uploading ??
fallbackDirectionCount(byState, UPLOAD_STATES);
const stateEntries = Object.entries(byState).sort( const stateEntries = Object.entries(byState).sort(
([stateA, countA], [stateB, countB]) => ([stateA, countA], [stateB, countB]) =>
countB - countA || stateLabel(stateA).localeCompare(stateLabel(stateB)), countB - countA || stateLabel(stateA).localeCompare(stateLabel(stateB)),
@@ -95,7 +98,9 @@ export function QbittorrentTotalsWidget({
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<div className="text-2xl font-semibold">{payload.total ?? 0}</div> <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>
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<div className="text-2xl font-semibold">{downloading}</div> <div className="text-2xl font-semibold">{downloading}</div>
@@ -126,7 +131,9 @@ export function QbittorrentTotalsWidget({
)} )}
</div> </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> </SectionCard>
); );