diff --git a/src/features/execute/ExecutePage.tsx b/src/features/execute/ExecutePage.tsx deleted file mode 100644 index f95481f..0000000 --- a/src/features/execute/ExecutePage.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { Link, useNavigate, useSearch } from "@tanstack/react-router"; -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { Badge } from "~/shared/components/ui/badge"; -import { Button } from "~/shared/components/ui/button"; -import { FilterTabs } from "~/shared/components/ui/filter-tabs"; -import { api } from "~/shared/lib/api"; -import type { Job, MediaItem } from "~/shared/lib/types"; - -interface JobEntry { - job: Job; - item: MediaItem | null; -} -interface ExecuteData { - jobs: JobEntry[]; - filter: string; - totalCounts: Record; -} - -const FILTER_TABS = [ - { key: "all", label: "All" }, - { key: "pending", label: "Pending" }, - { key: "running", label: "Running" }, - { key: "done", label: "Done" }, - { key: "error", label: "Error" }, -]; - -function itemName(job: Job, item: MediaItem | null): string { - if (!item) return `Item #${job.item_id}`; - if (item.type === "Episode" && item.series_name) { - return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, "0")}E${String(item.episode_number ?? 0).padStart(2, "0")}`; - } - return item.name; -} - -function jobTypeLabel(job: Job): string { - return job.job_type === "transcode" ? "Audio Transcode" : "Audio Remux"; -} - -// Module-level cache for instant tab switching -const cache = new Map(); - -export function ExecutePage() { - const { filter } = useSearch({ from: "/execute" }); - const navigate = useNavigate(); - const [data, setData] = useState(cache.get(filter) ?? null); - const [loading, setLoading] = useState(!cache.has(filter)); - const [logs, setLogs] = useState>(new Map()); - const [logVisible, setLogVisible] = useState>(new Set()); - const [cmdVisible, setCmdVisible] = useState>(new Set()); - const esRef = useRef(null); - const reloadTimerRef = useRef | null>(null); - - const load = useCallback( - (f?: string) => { - const key = f ?? filter; - const cached = cache.get(key); - if (cached && key === filter) { - setData(cached); - setLoading(false); - } else if (key === filter) { - setLoading(true); - } - api - .get(`/api/execute?filter=${key}`) - .then((d) => { - cache.set(key, d); - if (key === filter) { - setData(d); - setLoading(false); - } - }) - .catch(() => { - if (key === filter) setLoading(false); - }); - }, - [filter], - ); - - useEffect(() => { - load(); - }, [load]); - - // SSE for live job updates - useEffect(() => { - const es = new EventSource("/api/execute/events"); - esRef.current = es; - es.addEventListener("job_update", (e) => { - const d = JSON.parse(e.data) as { id: number; status: string; output?: string }; - - // Update job in current list if present - setData((prev) => { - if (!prev) return prev; - const jobIdx = prev.jobs.findIndex((j) => j.job.id === d.id); - if (jobIdx === -1) return prev; - - const oldStatus = prev.jobs[jobIdx].job.status; - const newStatus = d.status as Job["status"]; - - // Live-update totalCounts - const newCounts = { ...prev.totalCounts }; - if (oldStatus !== newStatus) { - if (newCounts[oldStatus] != null) newCounts[oldStatus]--; - if (newCounts[newStatus] != null) newCounts[newStatus]++; - else newCounts[newStatus] = 1; - } - - return { - ...prev, - totalCounts: newCounts, - jobs: prev.jobs.map((j) => (j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j)), - }; - }); - - if (d.output !== undefined) { - setLogs((prev) => { - const m = new Map(prev); - m.set(d.id, d.output!); - return m; - }); - } - - // Debounced reload on terminal state for accurate list - if (d.status === "done" || d.status === "error") { - if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); - reloadTimerRef.current = setTimeout(() => { - // Invalidate cache and reload current filter - cache.clear(); - load(); - }, 1000); - } - }); - return () => { - es.close(); - if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); - }; - }, [load]); - - const startAll = async () => { - await api.post("/api/execute/start"); - cache.clear(); - load(); - }; - const clearQueue = async () => { - await api.post("/api/execute/clear"); - cache.clear(); - load(); - }; - const clearCompleted = async () => { - await api.post("/api/execute/clear-completed"); - cache.clear(); - load(); - }; - const runJob = async (id: number) => { - await api.post(`/api/execute/job/${id}/run`); - cache.clear(); - load(); - }; - const cancelJob = async (id: number) => { - await api.post(`/api/execute/job/${id}/cancel`); - cache.clear(); - load(); - }; - - const toggleLog = (id: number) => - setLogVisible((prev) => { - const s = new Set(prev); - s.has(id) ? s.delete(id) : s.add(id); - return s; - }); - const toggleCmd = (id: number) => - setCmdVisible((prev) => { - const s = new Set(prev); - s.has(id) ? s.delete(id) : s.add(id); - return s; - }); - - const totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 }; - const pending = totalCounts.pending ?? 0; - const done = totalCounts.done ?? 0; - const errors = totalCounts.error ?? 0; - const jobs = data?.jobs ?? []; - - const running = totalCounts.running ?? 0; - const allDone = totalCounts.all > 0 && pending === 0 && running === 0; - - return ( -
-

Execute Jobs

- -
- {totalCounts.all === 0 && !loading && No jobs yet.} - {totalCounts.all === 0 && loading && Loading...} - {allDone && All jobs completed} - {running > 0 && ( - - {running} job{running !== 1 ? "s" : ""} running - - )} - {pending > 0 && ( - <> - - {pending} job{pending !== 1 ? "s" : ""} pending - - - - - )} - {(done > 0 || errors > 0) && ( - - )} -
- - navigate({ to: "/execute", search: { filter: key } as never })} - /> - - {loading && !data &&
Loading…
} - - {jobs.length > 0 && ( -
- - - - {["#", "Item", "Type", "Status", "Actions"].map((h) => ( - - ))} - - - - {jobs.map(({ job, item }: JobEntry) => { - const name = itemName(job, item); - const jobLog = logs.get(job.id) ?? job.output ?? ""; - const showLog = logVisible.has(job.id) || job.status === "running" || job.status === "error"; - const showCmd = cmdVisible.has(job.id); - - return ( - - - - - - - - - {showCmd && ( - - - - )} - {jobLog && showLog && ( - - - - )} - - ); - })} - -
- {h} -
{job.id} -
- {item ? ( - - {name} - - ) : ( - name - )} -
-
- {jobTypeLabel(job)} - - {job.status} - {job.exit_code != null && job.exit_code !== 0 && ( - - exit {job.exit_code} - - )} - -
- {job.status === "pending" && ( - <> - - - - )} - - {(job.status === "done" || job.status === "error") && jobLog && ( - - )} -
-
-
- {job.command} -
-
-
- {jobLog} -
-
-
- )} - - {!loading && jobs.length === 0 && totalCounts.all > 0 && ( -

No jobs match this filter.

- )} -
- ); -} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 2f9f7be..d9b33a6 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -69,7 +69,6 @@ function RootLayout() { Pipeline Subtitles - Jobs
diff --git a/src/routes/execute.tsx b/src/routes/execute.tsx deleted file mode 100644 index 1d2a2d7..0000000 --- a/src/routes/execute.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { z } from "zod"; -import { ExecutePage } from "~/features/execute/ExecutePage"; - -export const Route = createFileRoute("/execute")({ - validateSearch: z.object({ - filter: z.enum(["all", "pending", "running", "done", "error"]).default("pending"), - }), - component: ExecutePage, -});