Retire review deploy and add live Actions
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AlertTriangle, Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react";
|
||||
import { Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react";
|
||||
import "./globals.css";
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui";
|
||||
|
||||
@@ -36,18 +36,17 @@ export function SettingsDialog({ status, refresh }) {
|
||||
function ManualBatches({ refresh }) { const [batches,setBatches]=useState([]); const load=()=>request("/api/manual-batches").then(setBatches).catch(()=>{}); return <Modal title="Manual batches" trigger={<Button variant="ghost" className="w-full justify-start" onClick={load}><FolderPlus/> Manual batches</Button>}><form className="flex flex-col gap-3 sm:flex-row" onSubmit={async e=>{e.preventDefault();try{await request("/api/manual-batches",{method:"POST",body:{path:e.currentTarget.path.value}});e.currentTarget.reset();load();refresh()}catch(error){alert(error.message)}}}><Input name="path" required placeholder="Folder under download root"/><Button>Add batch</Button></form><div className="mt-4 grid gap-2">{batches.length?batches.map(b=><div className="rounded-md border p-3 text-sm" key={b.id}><strong>#{b.id} · {b.status}</strong><p className="break-all text-muted-foreground">{b.path}</p></div>):<p className="text-muted-foreground">No manual batches.</p>}</div></Modal>; }
|
||||
|
||||
export function App(){
|
||||
const [status,setStatus]=useState(null), [jobs,setJobs]=useState([]), [update,setUpdate]=useState(null), [menu,setMenu]=useState(false);
|
||||
const [status,setStatus]=useState(null), [jobs,setJobs]=useState([]), [menu,setMenu]=useState(false);
|
||||
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); } };
|
||||
useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]);
|
||||
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}},[]);
|
||||
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();return()=>{stopped=true}},[]);
|
||||
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 current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current;
|
||||
return <div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-30 border-b bg-card/95 backdrop-blur"><div className="mx-auto flex max-w-7xl items-center gap-3 p-4"><Download className="h-7 w-7 text-accent"/><div><h1 className="text-xl font-bold">Importarr</h1><p className="text-xs capitalize text-muted-foreground">{status?.control?.queue_mode||"Connecting"}</p></div><div className="ml-auto hidden max-w-md truncate text-sm text-muted-foreground sm:block">{currentName||"No active import"}</div><Button size="icon" aria-label="Start imports" onClick={()=>control("start")}><Play/></Button><Button size="icon" variant="outline" aria-label="Pause imports" onClick={()=>control("pause")}><Pause/></Button><div className="relative"><Button size="icon" variant="outline" aria-label="Open menu" onClick={()=>setMenu(!menu)}><Menu/></Button>{menu&&<Card className="absolute right-0 mt-2 w-60 p-2"><SettingsDialog status={status} refresh={refresh}/><ManualBatches refresh={refresh}/><Modal title="Service info" trigger={<Button variant="ghost" className="w-full justify-start"><Info/> Service info</Button>}><dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=><React.Fragment key={k}><dt className="font-medium">{k}</dt><dd className="break-all text-muted-foreground">{String(v)}</dd></React.Fragment>)}</dl></Modal><Button variant="ghost" className="w-full justify-start" onClick={()=>setDark(!dark)}>{dark?<Sun/>:<Moon/>}{dark?"Light":"Dark"} theme</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("stop")}><Square/> Stop queue</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("cancel-current")}><XCircle/> Cancel current</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>post("/api/import/run-now",{force:true})}><Zap/> Force run now</Button></Card>}</div></div></header>
|
||||
<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>}
|
||||
<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>{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>
|
||||
|
||||
@@ -35,13 +35,6 @@ describe("Importarr UI behavior", () => {
|
||||
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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user