Migrate UI to React and Tailwind Refs #32
This commit is contained in:
@@ -12,3 +12,4 @@ deploy/importarr.review.env
|
||||
*.egg-info
|
||||
.coverage
|
||||
htmlcov
|
||||
frontend/node_modules
|
||||
|
||||
@@ -8,3 +8,4 @@ __pycache__/
|
||||
AGENTS.local.md
|
||||
.review-data/
|
||||
deploy/importarr.review.env
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /build/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY importarr ./importarr
|
||||
COPY --from=frontend /build/importarr/static ./importarr/static
|
||||
ARG IMPORTARR_VERSION=0.1.0
|
||||
ARG IMPORTARR_BUILD_DATE=unknown
|
||||
ARG IMPORTARR_GIT_SHA=unknown
|
||||
|
||||
@@ -91,10 +91,18 @@ Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` to require `Authorizat
|
||||
python3.12 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e '.[test]'
|
||||
npm --prefix frontend install
|
||||
npm --prefix frontend run build
|
||||
pytest
|
||||
uvicorn importarr.main:app --reload
|
||||
```
|
||||
|
||||
The web UI is a Vite React application styled with Tailwind CSS. Its local
|
||||
shadcn-style component primitives use Radix UI for dialogs and composition, and
|
||||
lucide-react for icons. Run `npm --prefix frontend run dev` for Vite's development
|
||||
server (it proxies API calls to port 8765), or build before running FastAPI so the
|
||||
production assets are written to `importarr/static`.
|
||||
|
||||
### Persistent local review environment
|
||||
|
||||
The review Compose stack builds the current working tree, including uncommitted
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#252B42" />
|
||||
<title>Importarr</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
|
||||
</html>
|
||||
Generated
+5604
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "importarr-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^2.1.8",
|
||||
"jsdom": "^25.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const buttonVariants = cva("inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary disabled:pointer-events-none disabled:opacity-50", {
|
||||
variants: { variant: { default: "bg-primary text-white hover:bg-secondary", secondary: "bg-secondary text-white hover:bg-primary", outline: "border bg-card hover:bg-input", ghost: "hover:bg-input", destructive: "bg-primary text-white hover:bg-secondary" }, size: { default: "h-9 px-4", icon: "h-9 w-9 p-0", sm: "h-8 px-3" } },
|
||||
defaultVariants: { variant: "default", size: "default" }
|
||||
});
|
||||
export function Button({ className, variant, size, asChild = false, ...props }) { const Comp = asChild ? Slot : "button"; return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />; }
|
||||
export function Input({ className, ...props }) { return <input className={cn("flex h-9 w-full rounded-md border bg-input px-3 py-1 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-secondary", className)} {...props} />; }
|
||||
export function Card({ className, ...props }) { return <section className={cn("rounded-lg border bg-card shadow-sm", className)} {...props} />; }
|
||||
export function CardHeader({ className, ...props }) { return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />; }
|
||||
export function CardTitle({ className, ...props }) { return <h2 className={cn("text-lg font-semibold", className)} {...props} />; }
|
||||
export function CardContent({ className, ...props }) { return <div className={cn("p-6 pt-0", className)} {...props} />; }
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
export function DialogContent({ className, children, ...props }) { return <DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" /><DialogPrimitive.Content className={cn("fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border bg-card p-6 shadow-xl", className)} {...props}>{children}<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground hover:text-foreground" aria-label="Close"><X className="h-4 w-4" /></DialogPrimitive.Close></DialogPrimitive.Content></DialogPrimitive.Portal>; }
|
||||
export function DialogHeader({ className, ...props }) { return <div className={cn("mb-4 space-y-1.5", className)} {...props} />; }
|
||||
export function DialogTitle({ className, ...props }) { return <DialogPrimitive.Title className={cn("text-lg font-semibold", className)} {...props} />; }
|
||||
export function Badge({ className, ...props }) { return <span className={cn("inline-flex rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-white", className)} {...props} />; }
|
||||
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #F8FAFC;
|
||||
--card: #FFFFFF;
|
||||
--input: #FFFFFF;
|
||||
--primary: #4A43EC;
|
||||
--secondary: #7171FF;
|
||||
--accent: #2AD1ED;
|
||||
--foreground: #1A1D2E;
|
||||
--muted-foreground: #64748B;
|
||||
--border: #E2E8F0;
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: #252B42;
|
||||
--card: #303753;
|
||||
--input: #23283B;
|
||||
--primary: #4A43EC;
|
||||
--secondary: #7171FF;
|
||||
--accent: #42ECF5;
|
||||
--foreground: #FFFFFF;
|
||||
--muted-foreground: #8B95B7;
|
||||
--border: #3D4668;
|
||||
}
|
||||
* { @apply border-border; }
|
||||
body { @apply m-0 min-w-0 bg-background text-foreground antialiased; }
|
||||
button, input { font: inherit; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
export const cn = (...inputs) => twMerge(clsx(inputs));
|
||||
@@ -0,0 +1,51 @@
|
||||
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 "./globals.css";
|
||||
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 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 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={}) {
|
||||
const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...options.headers},body:JSON.stringify(options.body)} : options);
|
||||
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();
|
||||
}
|
||||
|
||||
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>; }
|
||||
|
||||
export function SettingsDialog({ status, refresh }) {
|
||||
const [result,setResult]=useState({});
|
||||
const [urls,setUrls]=useState({sab_url:"",radarr_url:"",sonarr_url:""});
|
||||
useEffect(()=>setUrls({sab_url:status?.sab_url||"",radarr_url:status?.radarr_url||"",sonarr_url:status?.sonarr_url||""}),[status?.sab_url,status?.radarr_url,status?.sonarr_url]);
|
||||
const submit=async e=>{ e.preventDefault(); try { await request("/api/settings",{method:"POST",body:Object.fromEntries(new FormData(e.currentTarget))}); e.currentTarget.reset(); await refresh(); } catch(error){ alert(error.message); } };
|
||||
const test=async (service,form)=>{ const prefix=service==="sabnzbd"?"sab":service; setResult(r=>({...r,[service]:"Testing…"})); try { const data=await request("/api/settings/test-connection",{method:"POST",body:{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}}); setResult(r=>({...r,[service]:data.message})); } catch(error){setResult(r=>({...r,[service]:error.message}))} };
|
||||
return <><Modal title="Settings" trigger={<Button variant="ghost" className="w-full justify-start"><Settings/> Settings</Button>}><form onSubmit={submit} className="grid gap-6">{[["sab","sabnzbd","SABnzbd"],["radarr","radarr","Radarr"],["sonarr","sonarr","Sonarr"]].map(([prefix,service,label])=><fieldset className="grid gap-3 rounded-md border p-4" key={service}><legend className="px-2 font-semibold">{label}</legend><Field label={`${label} URL`} name={`${prefix}_url`} type="url" required={prefix==="sab"} value={urls[`${prefix}_url`]} onChange={e=>setUrls(current=>({...current,[e.target.name]:e.target.value}))}/><Field label="API token" name={`${prefix}_api_key`} type="password" autoComplete="off" placeholder={status?.[`${prefix}_api_key_configured`]?"Configured; enter replacement":"API token"}/><div className="flex items-center gap-3"><Button type="button" variant="outline" onClick={e=>test(service,e.currentTarget.form)}>Test connection</Button><span className="text-sm text-muted-foreground">{result[service]}</span></div></fieldset>)}<p className="text-sm text-muted-foreground">Blank token fields clear stored tokens.</p><Button>Save settings</Button></form></Modal><Button variant="ghost" className="w-full justify-start" onClick={async()=>{try{await request("/api/import/run-now",{method:"POST",body:{force:true}});await refresh()}catch(error){alert(error.message)}}}><Zap/> Force run now</Button></>;
|
||||
}
|
||||
|
||||
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 [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(()=>{refresh();const id=setInterval(refresh,2000);request("/api/control/update-check").then(setUpdate).catch(()=>{});return()=>clearInterval(id)},[]);
|
||||
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")}>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><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 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>
|
||||
}
|
||||
const root=document.getElementById("root");
|
||||
if(root) createRoot(root).render(<React.StrictMode><App/></React.StrictMode>);
|
||||
@@ -0,0 +1,29 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SettingsDialog } from "./main";
|
||||
|
||||
describe("Importarr UI behavior", () => {
|
||||
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("hydrates settings URLs when status arrives asynchronously", async () => {
|
||||
const { rerender } = render(<SettingsDialog status={null} refresh={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
|
||||
expect(screen.getByLabelText("SABnzbd URL")).toHaveValue("");
|
||||
|
||||
rerender(<SettingsDialog status={{ sab_url: "http://sab", radarr_url: "http://radarr", sonarr_url: "http://sonarr" }} refresh={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("SABnzbd URL")).toHaveValue("http://sab"));
|
||||
expect(screen.getByLabelText("Radarr URL")).toHaveValue("http://radarr");
|
||||
expect(screen.getByLabelText("Sonarr URL")).toHaveValue("http://sonarr");
|
||||
});
|
||||
|
||||
it("posts a forced global run", async () => {
|
||||
fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
render(<SettingsDialog status={null} refresh={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Force run now" }));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) })));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)", foreground: "var(--foreground)",
|
||||
card: "var(--card)", input: "var(--input)", primary: "var(--primary)",
|
||||
secondary: "var(--secondary)", accent: "var(--accent)",
|
||||
muted: { foreground: "var(--muted-foreground)" }, border: "var(--border)"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/static/",
|
||||
server: { proxy: { "/api": "http://127.0.0.1:8765", "/health": "http://127.0.0.1:8765" } },
|
||||
build: {
|
||||
outDir: resolve(import.meta.dirname, "../importarr/static"),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: { output: { entryFileNames: "assets/app.js", assetFileNames: "assets/app.[ext]" } }
|
||||
}
|
||||
});
|
||||
+7
-8
@@ -10,10 +10,9 @@ from typing import Annotated
|
||||
|
||||
import uvicorn
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .build_info import build_info
|
||||
@@ -26,9 +25,9 @@ from .state import State
|
||||
|
||||
settings = Settings.from_env()
|
||||
state = State(settings.state_path)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
app = FastAPI(title="Importarr")
|
||||
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
WORKER_ID = f"importarr-{os.getpid()}"
|
||||
WORKER_SHUTDOWN_TIMEOUT_SECONDS = 5.0
|
||||
_worker_thread: threading.Thread | None = None
|
||||
@@ -106,9 +105,9 @@ def health() -> dict[str, str]:
|
||||
return {"status": "ok", "name": "Importarr", "version": build_info()["version"]}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "index.html", {"status": status(), "batches": state.list_manual_batches()})
|
||||
@app.get("/", response_class=FileResponse)
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,281 +0,0 @@
|
||||
:root,
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #fff;
|
||||
--surface: #fff;
|
||||
--surface-2: #f2f7ff;
|
||||
--line: #d9e6fb;
|
||||
--text: #2f3b52;
|
||||
--muted: #6f86a4;
|
||||
--heading: #3f3db6;
|
||||
--primary: #4b42b8;
|
||||
--primary-2: #776df2;
|
||||
--accent: #3074c2;
|
||||
--cyan: #168992;
|
||||
--purple: #7d4ed8;
|
||||
--danger: #7d4ed8;
|
||||
--warn: #ff9f0a;
|
||||
--shadow: #4b42b814;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #2f3d55;
|
||||
--surface: #40536f;
|
||||
--surface-2: #374963;
|
||||
--line: #506684;
|
||||
--text: #f8fbff;
|
||||
--muted: #bed1e9;
|
||||
--heading: #fff;
|
||||
--primary: #776df2;
|
||||
--primary-2: #4b42b8;
|
||||
--accent: #7db3f1;
|
||||
--cyan: #50dce5;
|
||||
--purple: #a56af0;
|
||||
--danger: #a56af0;
|
||||
--warn: #ff9f0a;
|
||||
--shadow: #18223666;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-theme="auto"] {
|
||||
color-scheme: dark;
|
||||
--bg: #2f3d55;
|
||||
--surface: #40536f;
|
||||
--surface-2: #374963;
|
||||
--line: #506684;
|
||||
--text: #f8fbff;
|
||||
--muted: #bed1e9;
|
||||
--heading: #fff;
|
||||
--primary: #776df2;
|
||||
--primary-2: #4b42b8;
|
||||
--accent: #7db3f1;
|
||||
--cyan: #50dce5;
|
||||
--purple: #a56af0;
|
||||
--danger: #a56af0;
|
||||
--warn: #ff9f0a;
|
||||
--shadow: #18223666;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
header,
|
||||
main {
|
||||
max-width: 1280px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, auto) auto;
|
||||
gap: .8rem;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: .45rem .9rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
box-shadow: 0 .45rem 1.2rem var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.top-status,
|
||||
.top-controls { min-width: 0; }
|
||||
.brand h1 { font-size: 1.35rem; margin: 0; color: var(--primary); }
|
||||
.brand p,
|
||||
.top-status span { margin: 0; color: var(--muted); }
|
||||
.top-status { text-align: center; overflow: hidden; }
|
||||
.top-status strong {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: .92rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .45rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.update-banner[hidden] { display: none; }
|
||||
.update-banner strong,
|
||||
.update-banner code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.top-controls { display: flex; align-items: center; gap: .3rem; }
|
||||
.top-controls>button,.menu summary {
|
||||
width: 2.25rem;
|
||||
height: 2.15rem;
|
||||
min-width: 2.25rem;
|
||||
min-height: 2.15rem;
|
||||
padding: .35rem;
|
||||
border-radius: .3rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: 1px solid var(--primary-2);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu { position: relative; }
|
||||
.menu summary { list-style: none; cursor: pointer; font-weight: 800; font-size: 1.15rem; }
|
||||
.menu summary::-webkit-details-marker { display: none; }
|
||||
.menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + .35rem);
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
min-width: 13rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
padding: .5rem;
|
||||
box-shadow: 0 .7rem 2rem var(--shadow);
|
||||
}
|
||||
.menu-panel a,
|
||||
.menu-panel button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--line);
|
||||
padding: .5rem;
|
||||
border-radius: .25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.menu-panel .danger { background: var(--purple); color: #fff; }
|
||||
|
||||
.update-banner { display: flex; align-items: center; justify-content: center; gap: .45rem; min-width: 0; color: #f59f00; font-weight: 800; }
|
||||
.update-banner[hidden] { display: none; }
|
||||
.update-banner button { border: 0; border-radius: .35rem; background: #ff9f0a; color: #fff; font-weight: 900; padding: .45rem .65rem; }
|
||||
.update-banner code { color: #d68b00; font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; }
|
||||
html[data-theme="dark"] .update-banner,
|
||||
html[data-theme="dark"] .update-banner code { color: #ffb020; }
|
||||
|
||||
main { padding: 0 .9rem .9rem; }
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
margin-top: .8rem;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 .6rem 1.5rem var(--shadow);
|
||||
}
|
||||
.summary-strip article,
|
||||
.panel,
|
||||
.queue-panel,
|
||||
table,
|
||||
dialog { background: var(--surface); }
|
||||
.summary-strip article { padding: .55rem; min-width: 0; }
|
||||
.summary-strip strong { display: block; font-size: .95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.summary-strip span,
|
||||
small,
|
||||
dd { color: var(--muted); }
|
||||
|
||||
.panel { padding: .8rem; margin-top: .8rem; border: 1px solid var(--line); box-shadow: 0 .5rem 1.3rem var(--shadow); }
|
||||
.packed { background: var(--surface-2); }
|
||||
.packed summary { cursor: pointer; font-weight: 800; font-size: 1rem; color: var(--primary); }
|
||||
.queue-panel { color: var(--text); }
|
||||
.section-title { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
|
||||
.section-title h2,
|
||||
.job-group h3 { font-size: 1.55rem; line-height: 1; margin: .2rem 0 .65rem; color: var(--heading); }
|
||||
.section-title span,
|
||||
.job-group h3 span { font-size: .85rem; color: var(--muted); }
|
||||
|
||||
.table-scroll { max-width: 100%; overflow: hidden; } /* overflow-x:auto intentionally avoided */
|
||||
table { width: 100%; border-collapse: collapse; margin-top: .35rem; table-layout: fixed; }
|
||||
.jobs-table th:nth-child(1) { width: 66%; }
|
||||
.jobs-table th:nth-child(2) { width: 12%; }
|
||||
.jobs-table th:nth-child(3) { width: 10%; }
|
||||
.jobs-table th:nth-child(4) { width: 12%; }
|
||||
.jobs-table td,
|
||||
td small,
|
||||
.file-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
.jobs-table .file-name { display: block; font-size: .9rem; line-height: 1.15; white-space: nowrap; }
|
||||
.jobs-table small { display: block; font-size: .76rem; line-height: 1.15; white-space: nowrap; }
|
||||
th,
|
||||
td { padding: .36rem .45rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
|
||||
th { font-size: .75rem; color: var(--accent); font-weight: 700; }
|
||||
|
||||
input,
|
||||
button { padding: .48rem; border-radius: .25rem; border: 1px solid var(--line); }
|
||||
input { background: var(--surface); color: var(--text); }
|
||||
button { background: var(--primary); color: #fff; font-weight: 700; cursor: pointer; min-height: 2.15rem; }
|
||||
.danger { background: var(--danger); color: #fff; }
|
||||
.warn { background: var(--warn); color: #1e1b4b; }
|
||||
.controls,
|
||||
.row-actions { display: flex; gap: .25rem; flex-wrap: nowrap; }
|
||||
.row-actions button { width: 1.85rem; min-width: 1.85rem; min-height: 1.85rem; padding: .2rem; font-size: .82rem; line-height: 1; }
|
||||
.inline-form { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
|
||||
.inline-form input[name=path] { min-width: min(100%, 28rem); flex: 1; }
|
||||
.info { display: grid; grid-template-columns: 10rem 1fr; gap: .35rem .8rem; }
|
||||
.info dt { font-weight: 700; }
|
||||
.info dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.state { background: var(--primary); border: 1px solid var(--primary-2); border-radius: 999px; padding: .08rem .42rem; display: inline-block; color: #fff; font-size: .75rem; font-weight: 800; line-height: 1.4; }
|
||||
.job-group { margin-top: .8rem; }
|
||||
.job-group h3 { display: flex; justify-content: space-between; }
|
||||
.job-cards { display: none; }
|
||||
.job-card { background: var(--surface-2); border: 1px solid var(--line); margin: .4rem 0; padding: .58rem; }
|
||||
.job-card .file-name { font-size: .95rem; word-break: break-word; }
|
||||
.job-card dl { display: grid; grid-template-columns: 5rem 1fr; gap: .25rem; margin: .45rem 0; }
|
||||
.job-card dt { font-weight: 700; }
|
||||
.job-card dd { margin: 0; word-break: break-word; }
|
||||
dialog { max-width: min(48rem, 95vw); color: var(--text); border: 1px solid var(--line); border-radius: .6rem; box-shadow: 0 1rem 3rem var(--shadow); }
|
||||
dialog::backdrop { background: #0f172a99; }
|
||||
fieldset { border: 1px solid var(--line); margin: .8rem 0; }
|
||||
label { display: grid; gap: .3rem; margin: .6rem 0; }
|
||||
.success { color: var(--cyan); }
|
||||
.error { color: var(--purple); }
|
||||
.hint { color: var(--muted); }
|
||||
.current-panel progress,
|
||||
.job-group progress { width: 100%; height: .8rem; }
|
||||
.dialog-body,
|
||||
#settings-form { min-width: min(42rem, 86vw); max-width: 58rem; }
|
||||
|
||||
@media (max-width:640px) {
|
||||
header,
|
||||
main { max-width: 1280px; }
|
||||
.topbar { grid-template-columns: auto minmax(0, 1fr) auto; padding: .4rem .55rem; gap: .45rem; }
|
||||
.update-banner { grid-column: 1 / -1; justify-content: flex-start; }
|
||||
.brand h1 { font-size: 1.05rem; }
|
||||
.brand p { font-size: .75rem; }
|
||||
.top-status { text-align: left; }
|
||||
.top-status span { display: none; }
|
||||
.top-status strong { font-size: .85rem; }
|
||||
.summary-strip { grid-template-columns: 1fr 1fr; }
|
||||
.summary-strip article { padding: .5rem; }
|
||||
.panel { padding: .65rem; }
|
||||
.section-title h2,
|
||||
.job-group h3 { font-size: 1.35rem; }
|
||||
.jobs-table { display: none; }
|
||||
.job-cards { display: block; }
|
||||
.controls,
|
||||
.inline-form { flex-direction: column; }
|
||||
.controls button,
|
||||
.inline-form button,
|
||||
.inline-form input { width: 100%; }
|
||||
.info { grid-template-columns: 1fr; }
|
||||
.menu-panel { right: 0; min-width: 12rem; }
|
||||
.job-card .file-name { word-break: break-word; }
|
||||
.row-actions { justify-content: flex-end; }
|
||||
.row-actions button { flex: 0 0 1.9rem; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#252B42" />
|
||||
<title>Importarr</title>
|
||||
<script type="module" crossorigin src="/static/assets/app.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/app.css">
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>
|
||||
@@ -1,160 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>📥 - idle · Importarr {{ status.build.version }}</title>
|
||||
<link rel="stylesheet" href="/static/importarr.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><h1>Importarr</h1><p id="ready-state">{{ 'Running' if status.control.queue_mode == 'start' else status.control.queue_mode|capitalize }}</p></div>
|
||||
<div class="top-status"><span>Current</span><strong id="top-current-job">{{ status.current or 'idle' }}</strong></div>
|
||||
<section id="update-banner" class="update-banner" hidden aria-live="polite">
|
||||
<strong id="update-message">New version available</strong>
|
||||
<button type="button" id="update-now">Update now</button>
|
||||
<code id="update-command">{{ status.build.version }}</code>
|
||||
</section>
|
||||
<div class="top-controls">
|
||||
<button type="button" data-control="start" aria-label="Start imports">▶</button>
|
||||
<button type="button" data-control="pause" aria-label="Pause imports">⏸</button>
|
||||
<details class="menu">
|
||||
<summary aria-label="Open menu">☰</summary>
|
||||
<div class="menu-panel">
|
||||
<button type="button" data-dialog="settings-dialog">Settings</button>
|
||||
<button type="button" data-dialog="service-info-dialog">Service info</button>
|
||||
<button type="button" data-dialog="queue-controls-dialog">Queue controls</button>
|
||||
<button type="button" data-dialog="manual-batches-dialog">Manual batches</button>
|
||||
<button type="button" id="theme-toggle">Theme: auto</button>
|
||||
<button type="button" data-control="stop">Stop queue</button>
|
||||
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
|
||||
<button id="force-run" type="button">Force run now</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="summary-strip" aria-label="Importarr summary">
|
||||
<article><strong>{{ status.control.queue_mode }}</strong><span>Queue mode</span></article>
|
||||
<article><strong>{{ status.current or 'Idle' }}</strong><span>Current import</span></article>
|
||||
<article><strong>{{ status.imported_total }}</strong><span>Imported</span></article>
|
||||
<article><strong>{{ status.failed_total }}</strong><span>Failed</span></article>
|
||||
<article><strong>{{ status.queue_total }}</strong><span>Queue items</span></article>
|
||||
</section>
|
||||
<section class="panel current-panel" aria-label="Current import details">
|
||||
<div class="section-title"><h2>Current import</h2><span id="current-runtime">idle</span></div>
|
||||
<p id="current-file">No active copy.</p>
|
||||
<progress id="current-progress" value="0" max="100"></progress>
|
||||
<p class="hint" id="current-size">Waiting for an active import.</p>
|
||||
</section>
|
||||
<section class="panel queue-panel">
|
||||
<div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
|
||||
<div id="jobs">Loading…</div>
|
||||
</section>
|
||||
</main>
|
||||
<dialog id="service-info-dialog">
|
||||
<div class="dialog-body">
|
||||
<div class="section-title"><h2>Service info and build details</h2><button type="button" data-close-dialog>Close</button></div>
|
||||
<dl class="info">
|
||||
<dt>Name</dt><dd>{{ status.build.name }}</dd>
|
||||
<dt>Version</dt><dd>{{ status.build.version }}</dd>
|
||||
<dt>Build date</dt><dd>{{ status.build.build_date }}</dd>
|
||||
<dt>Git SHA</dt><dd>{{ status.build.git_sha }}</dd>
|
||||
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
|
||||
<dt>Python</dt><dd>{{ status.build.python }}</dd>
|
||||
<dt>SAB URL</dt><dd>{{ status.sab_url }}</dd>
|
||||
<dt>SAB API token</dt><dd id="sab-token-status">{{ 'configured' if status.sab_api_key_configured else 'not configured' }}</dd>
|
||||
<dt>Radarr</dt><dd>{{ status.radarr_url or 'not configured' }}</dd>
|
||||
<dt>Sonarr</dt><dd>{{ status.sonarr_url or 'not configured' }}</dd>
|
||||
<dt>Download root</dt><dd>{{ status.download_root }}</dd>
|
||||
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
|
||||
<dt>TV root</dt><dd>{{ status.tv_root }}</dd>
|
||||
<dt>Write auth</dt><dd>{{ 'enabled' if status.auth_enabled else 'disabled' }}</dd>
|
||||
<dt>Queue mode</dt><dd id="queue-mode">{{ status.control.queue_mode }}</dd>
|
||||
<dt>Current job</dt><dd id="current-job">{{ status.current or 'idle' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="queue-controls-dialog">
|
||||
<div class="dialog-body">
|
||||
<div class="section-title"><h2>Queue controls</h2><button type="button" data-close-dialog>Close</button></div>
|
||||
<p>Pause and stop prevent new jobs from being added to the queue. They do not interrupt an import already in progress; use cancel current job for that.</p>
|
||||
<div class="controls">
|
||||
<button type="button" data-control="start">Start</button>
|
||||
<button type="button" data-control="pause">Pause</button>
|
||||
<button type="button" data-control="stop">Stop</button>
|
||||
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="manual-batches-dialog">
|
||||
<div class="dialog-body">
|
||||
<div class="section-title"><h2>Manual batches</h2><button type="button" data-close-dialog>Close</button></div>
|
||||
<form id="batch-form" class="inline-form">
|
||||
<input name="path" placeholder="folder under download root">
|
||||
<input id="batch-picker" type="file" webkitdirectory directory multiple hidden>
|
||||
<button type="button" id="browse-batch">Browse…</button>
|
||||
<button>Add batch</button>
|
||||
</form>
|
||||
<div class="table-scroll"><table><thead><tr><th>ID</th><th>Status</th><th>Path</th></tr></thead><tbody>
|
||||
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="settings-dialog">
|
||||
<form id="settings-form" method="dialog">
|
||||
<div class="section-title"><h2>Settings</h2><button type="button" data-close-dialog>Close</button></div>
|
||||
<fieldset>
|
||||
<legend>SABnzbd</legend>
|
||||
<label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label>
|
||||
<label>API token <input name="sab_api_key" type="password" placeholder="{% if status.sab_api_key_configured %}Configured; enter a new token to replace{% else %}SAB API token{% endif %}" autocomplete="off"></label>
|
||||
<button type="button" data-test-connection="sabnzbd">Test SABnzbd connection</button><output id="sabnzbd-test-result"></output>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Radarr</legend>
|
||||
<label>Radarr URL <input name="radarr_url" type="url" value="{{ status.radarr_url }}" placeholder="http://radarr:7878"></label>
|
||||
<label>API token <input name="radarr_api_key" type="password" placeholder="{% if status.radarr_api_key_configured %}Configured; enter a new token to replace{% else %}Radarr API token{% endif %}" autocomplete="off"></label>
|
||||
<button type="button" data-test-connection="radarr">Test Radarr connection</button><output id="radarr-test-result"></output>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Sonarr</legend>
|
||||
<label>Sonarr URL <input name="sonarr_url" type="url" value="{{ status.sonarr_url }}" placeholder="http://sonarr:8989"></label>
|
||||
<label>API token <input name="sonarr_api_key" type="password" placeholder="{% if status.sonarr_api_key_configured %}Configured; enter a new token to replace{% else %}Sonarr API token{% endif %}" autocomplete="off"></label>
|
||||
<button type="button" data-test-connection="sonarr">Test Sonarr connection</button><output id="sonarr-test-result"></output>
|
||||
</fieldset>
|
||||
<p class="hint">Blank token fields clear the stored token. Environment values remain the startup defaults until saved here.</p>
|
||||
<button type="submit">Save settings</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>
|
||||
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||||
async function postJson(url, body){ const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:body?JSON.stringify(body):undefined}); if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); alert(error.detail||'Request failed'); } return response; }
|
||||
function renderUpdateCheck(update){ const banner=document.getElementById('update-banner'); if(!update?.update_available){ banner.hidden=true; return; } document.getElementById('update-message').textContent=`↑ New version available: ${update.latest_version}`; document.getElementById('update-command').textContent=`current ${update.current_version}`; banner.hidden=false; }
|
||||
async function checkForUpdates(){ const response=await fetch('/api/control/update-check'); if(!response.ok) return; renderUpdateCheck(await response.json()); }
|
||||
function actionButtons(j){ const buttons=[]; if(j.can_run_now) buttons.push(`<button type="button" data-action="run-now" data-id="${j.id}" title="Run now">▶</button>`); if(j.can_retry) buttons.push(`<button type="button" data-action="retry" data-id="${j.id}" title="Retry">↻</button>`); if(j.can_ignore) buttons.push(`<button type="button" data-action="ignore" data-id="${j.id}" class="warn" title="Ignore">!</button>`); if(j.can_remove) buttons.push(`<button type="button" data-action="remove" data-id="${j.id}" class="danger" title="Remove">🗑</button>`); return buttons.join(' '); }
|
||||
function jobSubtext(j){ return `${esc(j.source_type)}${j.batch_id?' · batch '+esc(j.batch_id):''} · attempts ${esc(j.attempt_count||0)} · ${esc(j.relative_path||j.storage||j.source_id)}`; }
|
||||
function readiness(j){ return `<span class="state" title="${esc(j.reason||j.state)}">${esc(j.state)}</span>`; }
|
||||
const fmtBytes=value=>{ const bytes=Number(value||0); if(!bytes)return 'size unknown'; const units=['B','KB','MB','GB','TB']; let n=bytes,i=0; while(n>=1024&&i<units.length-1){n/=1024;i++;} return `${n.toFixed(n>=10||i===0?0:1)} ${units[i]}`; };
|
||||
const fmtDuration=value=>{ const s=Math.max(0,Math.floor(Number(value||0))); const m=Math.floor(s/60); const r=s%60; return m?`${m}m ${String(r).padStart(2,'0')}s`:`${r}s`; };
|
||||
function progressBar(j){ const p=Number(j.progress_percent||0); if(!p)return ''; return `<progress value="${p}" max="100"></progress><small>${p.toFixed(1)}% · ${fmtBytes(j.bytes_copied)} / ${fmtBytes(j.size)}</small>`; }
|
||||
function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '<p>No queue items.</p>'; return groups.map(group=>`<section class="job-group"><h3>${esc(group.label)} <span>${group.jobs.length}</span></h3><div class="table-scroll jobs-table"><table><thead><tr><th>File</th><th>Readiness</th><th>SAB</th><th>Progress</th><th>Actions</th></tr></thead><tbody>${group.jobs.map(j=>`<tr><td><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small></td><td>${readiness(j)}</td><td><small>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</small></td><td>${progressBar(j)}</td><td class="row-actions">${actionButtons(j)}</td></tr>`).join('')}</tbody></table></div><div class="job-cards">${group.jobs.map(j=>`<article class="job-card"><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small><dl><dt>Readiness</dt><dd>${readiness(j)}</dd><dt>SAB</dt><dd>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</dd><dt>Progress</dt><dd>${progressBar(j)||'—'}</dd></dl><div class="row-actions">${actionButtons(j)}</div></article>`).join('')}</div></section>`).join(''); }
|
||||
function renderCurrent(control){ const current=control?.current||null; const label=typeof current==='object'?(current.file||current.name||'active copy'):current; document.getElementById('top-current-job').textContent=label||'idle'; document.getElementById('current-job').textContent=label||'idle'; document.getElementById('current-file').textContent=label?`Copying ${label}`:'No active copy.'; const progress=typeof current==='object'?Number(current.percent||0):0; document.getElementById('current-progress').value=progress; document.getElementById('current-runtime').textContent=label?(current.elapsed_seconds!==undefined?fmtDuration(current.elapsed_seconds):'running'):'idle'; document.getElementById('current-size').textContent=label?(progress?`${progress.toFixed(1)}% · ${fmtBytes(current.bytes_copied)} / ${fmtBytes(current.total_bytes)}`:fmtBytes(current.total_bytes)):'Waiting for an active import.'; }
|
||||
async function refresh(){ const r=await fetch('/api/jobs'); const d=await r.json(); document.title=d.jobs.length?`📥 ${d.jobs.length} jobs - Importarr`:'📥 - idle · Importarr'; document.getElementById('jobs').innerHTML=renderJobs(d); if(d.control){ document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('ready-state').textContent=d.control.queue_mode==='start'?'Running':d.control.queue_mode; renderCurrent(d.control); } }
|
||||
document.getElementById('jobs').addEventListener('click', async e=>{ const button=e.target.closest('button[data-action]'); if(!button)return; const action=button.dataset.action; const destructive=['ignore','remove'].includes(action); if(destructive&&!confirm(`${action} this Importarr queue item?`)) return; await postJson(`/api/queue-items/${button.dataset.id}/action`,{action}); await refresh(); });
|
||||
document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ if(button.dataset.control==='cancel-current'&&!confirm('Cancel the current import job?')) return; await postJson(`/api/control/${button.dataset.control}`); await refresh(); }));
|
||||
document.getElementById('browse-batch').addEventListener('click',()=>document.getElementById('batch-picker').click());
|
||||
document.getElementById('batch-picker').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f)return; const top=(f.webkitRelativePath||'').split('/')[0]; if(top) document.querySelector('#batch-form [name="path"]').value=top; });
|
||||
document.getElementById('batch-form').addEventListener('submit', async e=>{ e.preventDefault(); const response=await postJson('/api/manual-batches',{path:e.target.path.value}); if(response.ok) location.reload(); });
|
||||
document.querySelectorAll('[data-dialog]').forEach(button=>button.addEventListener('click',()=>{ document.querySelector('details.menu')?.removeAttribute('open'); document.getElementById(button.dataset.dialog).showModal(); }));
|
||||
document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>button.closest('dialog').close()));
|
||||
document.addEventListener('click',e=>{ document.querySelectorAll('details.menu[open]').forEach(menu=>{ if(!menu.contains(e.target)) menu.removeAttribute('open'); }); });
|
||||
document.querySelectorAll('dialog').forEach(dialog=>dialog.addEventListener('click',e=>{ if(e.target===e.currentTarget) e.currentTarget.close(); }));
|
||||
document.getElementById('settings-form').addEventListener('submit', async e=>{ e.preventDefault(); const body=Object.fromEntries(new FormData(e.target)); const response=await postJson('/api/settings',body); if(response.ok){ const data=await response.json(); document.getElementById('sab-token-status').textContent=data.sab_api_key_configured?'configured':'not configured'; ['sab_api_key','radarr_api_key','sonarr_api_key'].forEach(name=>e.target.elements[name].value=''); document.getElementById('settings-dialog').close(); } });
|
||||
document.querySelectorAll('[data-test-connection]').forEach(button=>button.addEventListener('click', async()=>{ const form=document.getElementById('settings-form'); const service=button.dataset.testConnection; const prefix=service==='sabnzbd'?'sab':service; const output=document.getElementById(`${service}-test-result`); output.textContent='Testing…'; output.className=''; const response=await postJson('/api/settings/test-connection',{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}); if(response.ok){ const data=await response.json(); output.textContent=data.message; output.className=data.ok?'success':'error'; } }));
|
||||
const themes=['auto','light','dark']; function applyTheme(theme){ document.documentElement.dataset.theme=theme; localStorage.setItem('importarr-theme',theme); document.getElementById('theme-toggle').textContent=`Theme: ${theme}`; } document.getElementById('theme-toggle').addEventListener('click',()=>applyTheme(themes[(themes.indexOf(document.documentElement.dataset.theme)+1)%themes.length])); applyTheme(localStorage.getItem('importarr-theme')||'auto');
|
||||
document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
|
||||
document.getElementById('update-now').addEventListener('click', async()=>{ if(!confirm('Update Importarr now?')) return; const response=await postJson('/api/control/update'); if(response.ok){ const data=await response.json(); renderUpdateCheck(data); alert(data.command_result?.stdout||data.status||'Update command started.'); } });
|
||||
refresh(); checkForUpdates(); setInterval(refresh, 2000); setInterval(checkForUpdates, 3600000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,7 +12,6 @@ license = "MIT"
|
||||
dependencies = [
|
||||
"fastapi>=0.111",
|
||||
"httpx>=0.27",
|
||||
"jinja2>=3.1",
|
||||
"pydantic>=2.7",
|
||||
"uvicorn[standard]>=0.30",
|
||||
]
|
||||
|
||||
+17
-95
@@ -33,7 +33,7 @@ def test_status_contains_service_configuration(tmp_path, monkeypatch):
|
||||
assert "auth_enabled" in payload
|
||||
|
||||
|
||||
def test_index_renders_queue_controls(tmp_path, monkeypatch):
|
||||
def test_index_serves_react_application(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||
import importarr.main as main
|
||||
|
||||
@@ -42,109 +42,31 @@ def test_index_renders_queue_controls(tmp_path, monkeypatch):
|
||||
response = TestClient(main.app).get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Queue controls" in response.text
|
||||
assert "cancel-current" in response.text
|
||||
assert '<div id="root"></div>' in response.text
|
||||
assert '/static/assets/app.js' in response.text
|
||||
assert '/static/assets/app.css' in response.text
|
||||
|
||||
|
||||
def test_index_packs_secondary_controls_into_menu(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||
import importarr.main as main
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
response = TestClient(main.app).get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'class="menu"' in response.text
|
||||
assert 'data-dialog="service-info-dialog"' in response.text
|
||||
assert 'data-dialog="queue-controls-dialog"' in response.text
|
||||
assert 'data-dialog="manual-batches-dialog"' in response.text
|
||||
assert "Service info and build details" in response.text
|
||||
assert "Queue and history" in response.text
|
||||
assert "Current import" in response.text
|
||||
assert "current-progress" in response.text
|
||||
assert "theme-toggle" in response.text
|
||||
assert "setInterval(refresh, 2000)" in response.text
|
||||
assert "details.menu[open]" in response.text
|
||||
assert "e.target===e.currentTarget" in response.text
|
||||
assert "function readiness" in response.text
|
||||
assert 'title="${esc(j.reason||j.state)}"' in response.text
|
||||
assert "update-banner" in response.text
|
||||
assert "checkForUpdates()" in response.text
|
||||
assert "/api/control/update-check" in response.text
|
||||
assert "update-now" in response.text
|
||||
|
||||
|
||||
def test_stylesheet_includes_theme_and_progress_rules():
|
||||
def test_frontend_uses_required_stack_and_capabilities():
|
||||
from pathlib import Path
|
||||
|
||||
css = Path("importarr/static/importarr.css").read_text()
|
||||
app = Path("frontend/src/main.jsx").read_text()
|
||||
package = Path("frontend/package.json").read_text()
|
||||
|
||||
assert 'html[data-theme="light"]' in css
|
||||
assert 'html[data-theme="dark"]' in css
|
||||
assert ".current-panel progress" in css
|
||||
assert ".top-controls>button" in css
|
||||
assert ".update-banner" in css
|
||||
assert "#ff9f0a" in css
|
||||
assert "lucide-react" in package
|
||||
assert "@radix-ui/react-dialog" in package
|
||||
assert "tailwindcss" in package
|
||||
for endpoint in ("/api/jobs", "/api/settings", "/api/manual-batches", "/api/control/update", "/api/import/run-now"):
|
||||
assert endpoint in app
|
||||
|
||||
|
||||
def test_index_renders_settings_dialog(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||
import importarr.main as main
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
response = TestClient(main.app).get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "settings-dialog" in response.text
|
||||
assert "SABnzbd" in response.text
|
||||
assert "Radarr" in response.text
|
||||
assert "Sonarr" in response.text
|
||||
assert "Test SABnzbd connection" in response.text
|
||||
assert "Test Radarr connection" in response.text
|
||||
assert "Test Sonarr connection" in response.text
|
||||
|
||||
|
||||
def test_index_renders_responsive_table_wrappers(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||
import importarr.main as main
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
response = TestClient(main.app).get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'class="table-scroll"' in response.text
|
||||
assert "job-cards" in response.text
|
||||
assert "job-card" in response.text
|
||||
assert '<meta name="viewport" content="width=device-width, initial-scale=1">' in response.text
|
||||
|
||||
|
||||
def test_stylesheet_includes_mobile_responsive_rules():
|
||||
def test_frontend_defines_exact_light_and_dark_tokens():
|
||||
from pathlib import Path
|
||||
|
||||
css = Path("importarr/static/importarr.css").read_text()
|
||||
|
||||
assert "@media (max-width:640px)" in css
|
||||
assert ".table-scroll" in css
|
||||
assert "overflow: hidden" in css
|
||||
assert "flex-direction: column" in css
|
||||
assert ".jobs-table { display: none" in css
|
||||
assert ".job-cards { display: block" in css
|
||||
assert "word-break: break-word" in css
|
||||
assert "max-width: 1280px" in css
|
||||
assert "table-layout: fixed" in css
|
||||
assert ".jobs-table th:nth-child(1) { width: 66%" in css
|
||||
assert ".row-actions button { width: 1.85rem" in css
|
||||
assert ".summary-strip" in css
|
||||
assert ".menu-panel" in css
|
||||
assert "@media (prefers-color-scheme: dark)" in css
|
||||
assert "--primary: #4b42b8" in css
|
||||
assert "--cyan: #50dce5" in css
|
||||
assert "--bg: #fff" in css
|
||||
assert "--bg: #2f3d55" in css
|
||||
assert "--danger: #a56af0" in css
|
||||
css = Path("frontend/src/globals.css").read_text()
|
||||
for value in ("#F8FAFC", "#FFFFFF", "#4A43EC", "#7171FF", "#2AD1ED", "#1A1D2E", "#64748B", "#E2E8F0", "#252B42", "#303753", "#23283B", "#42ECF5", "#8B95B7", "#3D4668"):
|
||||
assert value in css
|
||||
assert ".dark" in css
|
||||
|
||||
|
||||
def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user