Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe90feb1b7 | |||
| 230b4b8533 |
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] },
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user