delete /execute page, route, and Jobs nav link

This commit is contained in:
2026-04-15 07:02:32 +02:00
parent 2eacda9127
commit f6488b6bbe
3 changed files with 0 additions and 345 deletions

View File

@@ -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<string, number>;
}
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<string, ExecuteData>();
export function ExecutePage() {
const { filter } = useSearch({ from: "/execute" });
const navigate = useNavigate();
const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter));
const [logs, setLogs] = useState<Map<number, string>>(new Map());
const [logVisible, setLogVisible] = useState<Set<number>>(new Set());
const [cmdVisible, setCmdVisible] = useState<Set<number>>(new Set());
const esRef = useRef<EventSource | null>(null);
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ExecuteData>(`/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 (
<div>
<h1 className="text-xl font-bold mb-4">Execute Jobs</h1>
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
{totalCounts.all === 0 && !loading && <span className="text-sm text-gray-500">No jobs yet.</span>}
{totalCounts.all === 0 && loading && <span className="text-sm text-gray-400">Loading...</span>}
{allDone && <span className="text-sm font-medium">All jobs completed</span>}
{running > 0 && (
<span className="text-sm font-medium">
{running} job{running !== 1 ? "s" : ""} running
</span>
)}
{pending > 0 && (
<>
<span className="text-sm font-medium">
{pending} job{pending !== 1 ? "s" : ""} pending
</span>
<Button size="sm" onClick={startAll}>
Run all pending
</Button>
<Button size="sm" variant="secondary" onClick={clearQueue}>
Clear queue
</Button>
</>
)}
{(done > 0 || errors > 0) && (
<Button size="sm" variant="secondary" onClick={clearCompleted}>
Clear done/errors
</Button>
)}
</div>
<FilterTabs
tabs={FILTER_TABS}
filter={filter}
totalCounts={totalCounts}
onFilterChange={(key) => navigate({ to: "/execute", search: { filter: key } as never })}
/>
{loading && !data && <div className="text-gray-400 py-8 text-center">Loading</div>}
{jobs.length > 0 && (
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{["#", "Item", "Type", "Status", "Actions"].map((h) => (
<th
key={h}
className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{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 (
<Fragment key={job.id}>
<tr key={job.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<div className="truncate max-w-[300px]" title={name}>
{item ? (
<Link
to="/review/audio/$id"
params={{ id: String(item.id) }}
className="text-inherit no-underline hover:underline"
>
{name}
</Link>
) : (
name
)}
</div>
</td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{jobTypeLabel(job)}</Badge>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={job.status}>{job.status}</Badge>
{job.exit_code != null && job.exit_code !== 0 && (
<Badge variant="error" className="ml-1">
exit {job.exit_code}
</Badge>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
<div className="flex gap-1 items-center">
{job.status === "pending" && (
<>
<Button size="sm" onClick={() => runJob(job.id)}>
Run
</Button>
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>
</Button>
</>
)}
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>
Cmd
</Button>
{(job.status === "done" || job.status === "error") && jobLog && (
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>
Log
</Button>
)}
</div>
</td>
</tr>
{showCmd && (
<tr key={`cmd-${job.id}`}>
<td colSpan={5} className="p-0 border-b border-gray-100">
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3.5 py-2.5 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all">
{job.command}
</div>
</td>
</tr>
)}
{jobLog && showLog && (
<tr key={`log-${job.id}`}>
<td colSpan={5} className="p-0 border-b border-gray-100">
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
{jobLog}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
{!loading && jobs.length === 0 && totalCounts.all > 0 && (
<p className="text-gray-500 text-center py-4">No jobs match this filter.</p>
)}
</div>
);
}

View File

@@ -69,7 +69,6 @@ function RootLayout() {
</NavLink>
<NavLink to="/pipeline">Pipeline</NavLink>
<NavLink to="/review/subtitles">Subtitles</NavLink>
<NavLink to="/execute">Jobs</NavLink>
</div>
<div className="flex-1" />
<div className="flex items-center gap-0.5">

View File

@@ -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,
});