Fix queue UI responsiveness

Add browser auth retry for protected UI actions and update checks.

Speed queue handoff and release v0.1.2.
This commit is contained in:
2026-08-09 07:37:04 +02:00
parent e8b0645ea6
commit 2d593f4b6c
7 changed files with 38 additions and 12 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "importarr-ui", "name": "importarr-ui",
"version": "0.1.1", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "importarr-ui", "name": "importarr-ui",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "importarr-ui", "name": "importarr-ui",
"private": true, "private": true,
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+11 -5
View File
@@ -4,17 +4,23 @@ import { AlertTriangle, Clock, Download, FolderPlus, History, Info, Menu, Moon,
import "./globals.css"; import "./globals.css";
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui"; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui";
const groups = ["sab_processing", "ready", "importing", "failed", "ignored_category", "manual_batch", "completed"]; const groups = ["ready", "importing", "failed", "manual_batch", "completed", "ignored_category", "sab_processing"];
const labels = { sab_processing: "SAB processing", ready: "Ready", importing: "Importing", failed: "Failed", ignored_category: "Ignored category", manual_batch: "Manual batch", completed: "Completed" }; const labels = { sab_processing: "SAB processing", ready: "Ready", importing: "Importing", failed: "Failed", ignored_category: "Ignored category", manual_batch: "Manual batch", completed: "Completed" };
const bytes = value => { let n=Number(value||0), i=0; const units=["B","KB","MB","GB","TB"]; if(!n)return "size unknown"; while(n>=1024&&i<4){n/=1024;i++} return `${n.toFixed(n>=10||!i?0:1)} ${units[i]}`; }; const bytes = value => { let n=Number(value||0), i=0; const units=["B","KB","MB","GB","TB"]; if(!n)return "size unknown"; while(n>=1024&&i<4){n/=1024;i++} return `${n.toFixed(n>=10||!i?0:1)} ${units[i]}`; };
const duration = value => { const s=Math.max(0,Math.floor(Number(value||0))); return s>=60?`${Math.floor(s/60)}m ${String(s%60).padStart(2,"0")}s`:`${s}s`; }; const duration = value => { const s=Math.max(0,Math.floor(Number(value||0))); return s>=60?`${Math.floor(s/60)}m ${String(s%60).padStart(2,"0")}s`:`${s}s`; };
async function request(url, options={}) { export async function request(url, requestOptions={}, retryAuth=true) {
const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...options.headers},body:JSON.stringify(options.body)} : options); const { auth=false, ...options }=requestOptions;
const protectedRequest=auth||(options.method&&options.method!=="GET"), token=protectedRequest?sessionStorage.getItem("importarr-auth-token"):null;
const headers={...options.headers,...(token?{Authorization:`Bearer ${token}`}:{})};
const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...headers},body:JSON.stringify(options.body)} : {...options,headers});
if(response.status===401&&protectedRequest&&retryAuth){const entered=prompt("Importarr write access token");if(entered){sessionStorage.setItem("importarr-auth-token",entered);return request(url,requestOptions,false)}}
if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); throw new Error(typeof error.detail==="string"?error.detail:"Request failed"); } if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); throw new Error(typeof error.detail==="string"?error.detail:"Request failed"); }
return response.status===204?null:response.json(); return response.status===204?null:response.json();
} }
export const pollDelay = failures => failures ? Math.min(5000,1000*2**(failures-1)) : 300;
function Modal({ trigger, title, children }) { return <Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent><DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>{children}</DialogContent></Dialog>; } function Modal({ trigger, title, children }) { return <Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent><DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>{children}</DialogContent></Dialog>; }
function Field({ label, ...props }) { return <label className="grid gap-2 text-sm font-medium">{label}<Input {...props}/></label>; } function Field({ label, ...props }) { return <label className="grid gap-2 text-sm font-medium">{label}<Input {...props}/></label>; }
@@ -34,7 +40,7 @@ export function App(){
const [dark,setDark]=useState(()=>localStorage.getItem("importarr-theme")!=="light" && (localStorage.getItem("importarr-theme")==="dark"||matchMedia("(prefers-color-scheme: dark)").matches)); const [dark,setDark]=useState(()=>localStorage.getItem("importarr-theme")!=="light" && (localStorage.getItem("importarr-theme")==="dark"||matchMedia("(prefers-color-scheme: dark)").matches));
const refresh=async()=>{ try { const [s,j]=await Promise.all([request("/api/status"),request("/api/jobs")]); setStatus(s);setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})));document.title=`${j.jobs?.length?`${j.jobs.length} jobs`:"Idle"} · Importarr`; } catch(error){ console.error(error); } }; const refresh=async()=>{ try { const [s,j]=await Promise.all([request("/api/status"),request("/api/jobs")]); setStatus(s);setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})));document.title=`${j.jobs?.length?`${j.jobs.length} jobs`:"Idle"} · Importarr`; } catch(error){ console.error(error); } };
useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]); useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]);
useEffect(()=>{refresh();const id=setInterval(refresh,2000);request("/api/control/update-check").then(setUpdate).catch(()=>{});return()=>clearInterval(id)},[]); useEffect(()=>{let stopped=false,lastCurrent,lastJobs=0,failures=0;const poll=async()=>{try{const s=await request("/api/status");if(stopped)return;setStatus(s);const current=typeof s.current==="object"?(s.current.file||s.current.name):s.current;if(current!==lastCurrent||Date.now()-lastJobs>(current?10000:2000)){lastCurrent=current;lastJobs=Date.now();const j=await request("/api/jobs");setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})))}failures=0}catch(error){failures++;console.error(error)}if(!stopped)setTimeout(poll,pollDelay(failures))};poll();request("/api/control/update-check",{auth:true}).then(setUpdate).catch(()=>{});return()=>{stopped=true}},[]);
const post=async(url,body)=>{try{await request(url,{method:"POST",body});await refresh()}catch(error){alert(error.message)}}; const post=async(url,body)=>{try{await request(url,{method:"POST",body});await refresh()}catch(error){alert(error.message)}};
const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)}; const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)};
const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current; const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current;
@@ -43,7 +49,7 @@ export function App(){
<main className="mx-auto grid max-w-7xl gap-4 p-4 sm:p-6"> <main className="mx-auto grid max-w-7xl gap-4 p-4 sm:p-6">
{update?.update_available&&<Card className="border-secondary"><CardContent className="flex flex-wrap items-center gap-3 p-4"><AlertTriangle className="text-accent"/><strong>Version {update.latest_version} is available</strong><Button className="ml-auto" onClick={()=>confirm("Update Importarr now?")&&post(`/api/control/update?expected_tag=${encodeURIComponent(update.latest_version)}`)}>Update now</Button></CardContent></Card>} {update?.update_available&&<Card className="border-secondary"><CardContent className="flex flex-wrap items-center gap-3 p-4"><AlertTriangle className="text-accent"/><strong>Version {update.latest_version} is available</strong><Button className="ml-auto" onClick={()=>confirm("Update Importarr now?")&&post(`/api/control/update?expected_tag=${encodeURIComponent(update.latest_version)}`)}>Update now</Button></CardContent></Card>}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=><Card key={label}><CardContent className="p-4"><strong className="block truncate text-lg capitalize">{value??"—"}</strong><span className="text-sm text-muted-foreground">{label}</span></CardContent></Card>)}</div> <div className="grid grid-cols-2 gap-4 lg:grid-cols-5">{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=><Card key={label}><CardContent className="p-4"><strong className="block truncate text-lg capitalize">{value??"—"}</strong><span className="text-sm text-muted-foreground">{label}</span></CardContent></Card>)}</div>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>Current import</CardTitle><p className="text-sm text-muted-foreground">{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}</p></div><Clock className="text-accent"/></CardHeader><CardContent><p className="mb-3 break-all text-sm">{currentName||"No active copy."}</p><progress className="h-2 w-full accent-accent" max="100" value={typeof current==="object"?current.percent||0:0}/>{currentName&&<p className="mt-2 text-xs text-muted-foreground">{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}</p>}</CardContent></Card> <Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>Current import</CardTitle><p className="text-sm text-muted-foreground">{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}</p></div><Clock className="text-accent"/></CardHeader><CardContent><p className="mb-3 break-all text-sm">{currentName||"No active copy."}</p>{currentName&&<><progress className="h-2 w-full accent-accent" max="100" value={typeof current==="object"?current.percent||0:0}/><p className="mt-2 text-xs text-muted-foreground">{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}</p></>}</CardContent></Card>
<Card><CardHeader className="flex-row items-center gap-3"><History className="text-accent"/><div><CardTitle>Queue and history</CardTitle><p className="text-sm text-muted-foreground">Grouped by processing state</p></div></CardHeader><CardContent className="grid gap-6">{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?<section key={group}><h3 className="mb-3 flex items-center gap-2 font-semibold">{labels[group]} <Badge>{items.length}</Badge></h3><div className="grid gap-2">{items.map(j=><article className="grid gap-3 rounded-md border bg-input p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" key={j.id}><div className="min-w-0"><strong className="block break-words">{j.name}</strong><p className="break-all text-xs text-muted-foreground">{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}</p><div className="mt-2 flex flex-wrap items-center gap-2"><Badge>{j.state}</Badge><span className="text-xs text-muted-foreground">{j.reason}</span></div></div><div className="flex gap-2">{j.can_run_now&&<Button size="icon" title="Run now" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"run-now"})}><Play/></Button>}{j.can_retry&&<Button size="icon" variant="outline" title="Retry" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"retry"})}><RefreshCw/></Button>}{j.can_ignore&&<Button size="icon" variant="outline" title="Ignore" onClick={()=>confirm("Ignore this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"ignore"})}><XCircle/></Button>}{j.can_remove&&<Button size="icon" variant="outline" title="Remove" onClick={()=>confirm("Remove this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"remove"})}><Trash2/></Button>}</div></article>)}</div></section>:null}):<p className="text-muted-foreground">No queue items.</p>}</CardContent></Card> <Card><CardHeader className="flex-row items-center gap-3"><History className="text-accent"/><div><CardTitle>Queue and history</CardTitle><p className="text-sm text-muted-foreground">Grouped by processing state</p></div></CardHeader><CardContent className="grid gap-6">{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?<section key={group}><h3 className="mb-3 flex items-center gap-2 font-semibold">{labels[group]} <Badge>{items.length}</Badge></h3><div className="grid gap-2">{items.map(j=><article className="grid gap-3 rounded-md border bg-input p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" key={j.id}><div className="min-w-0"><strong className="block break-words">{j.name}</strong><p className="break-all text-xs text-muted-foreground">{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}</p><div className="mt-2 flex flex-wrap items-center gap-2"><Badge>{j.state}</Badge><span className="text-xs text-muted-foreground">{j.reason}</span></div></div><div className="flex gap-2">{j.can_run_now&&<Button size="icon" title="Run now" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"run-now"})}><Play/></Button>}{j.can_retry&&<Button size="icon" variant="outline" title="Retry" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"retry"})}><RefreshCw/></Button>}{j.can_ignore&&<Button size="icon" variant="outline" title="Ignore" onClick={()=>confirm("Ignore this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"ignore"})}><XCircle/></Button>}{j.can_remove&&<Button size="icon" variant="outline" title="Remove" onClick={()=>confirm("Remove this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"remove"})}><Trash2/></Button>}</div></article>)}</div></section>:null}):<p className="text-muted-foreground">No queue items.</p>}</CardContent></Card>
</main></div> </main></div>
} }
+21 -2
View File
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsDialog } from "./main"; import { pollDelay, request, SettingsDialog } from "./main";
describe("Importarr UI behavior", () => { describe("Importarr UI behavior", () => {
beforeEach(() => vi.stubGlobal("fetch", vi.fn())); beforeEach(() => { sessionStorage.clear(); vi.stubGlobal("fetch", vi.fn()); });
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it("hydrates settings URLs when status arrives asynchronously", async () => { it("hydrates settings URLs when status arrives asynchronously", async () => {
@@ -26,4 +27,22 @@ describe("Importarr UI behavior", () => {
await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) }))); await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) })));
}); });
it("prompts for write auth and retries with the entered token", async () => {
fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true }) });
vi.stubGlobal("prompt", vi.fn(() => "browser-token"));
await request("/api/queue-items/1/action", { method: "POST", body: { action: "run-now" } });
expect(fetch).toHaveBeenLastCalledWith("/api/queue-items/1/action", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) }));
});
it("authenticates the protected update-check GET", async () => {
fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ update_available: false }) });
vi.stubGlobal("prompt", vi.fn(() => "browser-token"));
await request("/api/control/update-check", { auth: true });
expect(fetch).toHaveBeenLastCalledWith("/api/control/update-check", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) }));
});
it("backs polling off after failures and immediately recovers its fast interval", () => {
expect([pollDelay(0),pollDelay(1),pollDelay(2),pollDelay(4)]).toEqual([300,1000,2000,5000]);
});
}); });
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.1" __version__ = "0.1.2"
+1
View File
@@ -286,6 +286,7 @@ def _worker_loop() -> None:
item = state.claim_next_queue_item(WORKER_ID) if queue_accepting_new_jobs() else None item = state.claim_next_queue_item(WORKER_ID) if queue_accepting_new_jobs() else None
if item is not None: if item is not None:
_import_queue_item(item, importer, from_worker=True) _import_queue_item(item, importer, from_worker=True)
continue
except Exception: except Exception:
state.upsert_queue_item(source_type="system", source_id="queue-worker", name="Queue worker", state="failed", reason="worker loop error") state.upsert_queue_item(source_type="system", source_id="queue-worker", name="Queue worker", state="failed", reason="worker loop error")
_worker_stop.wait(settings.poll_seconds) _worker_stop.wait(settings.poll_seconds)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "importarr" name = "importarr"
version = "0.1.1" version = "0.1.2"
description = "Arr-style manual SABnzbd import service" description = "Arr-style manual SABnzbd import service"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"