Migrate UI to React and Tailwind Refs #32

This commit is contained in:
2026-07-30 14:54:46 +02:00
parent fb955adaf8
commit 65c96bbc4b
23 changed files with 6061 additions and 545 deletions
+1
View File
@@ -12,3 +12,4 @@ deploy/importarr.review.env
*.egg-info
.coverage
htmlcov
frontend/node_modules
+1
View File
@@ -8,3 +8,4 @@ __pycache__/
AGENTS.local.md
.review-data/
deploy/importarr.review.env
frontend/node_modules/
+8
View File
@@ -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
+8
View File
@@ -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
+10
View File
@@ -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>
+5604
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+23
View File
@@ -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} />; }
+33
View File
@@ -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; }
}
+3
View File
@@ -0,0 +1,3 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs) => twMerge(clsx(inputs));
+51
View File
@@ -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>);
+29
View File
@@ -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 }) })));
});
});
+16
View File
@@ -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: []
};
+14
View File
@@ -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
View File
@@ -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
-281
View File
@@ -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; }
}
+12
View File
@@ -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>
-160
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
-1
View File
@@ -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
View File
@@ -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):