delete /execute page, route, and Jobs nav link
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user