feat(pipeline): tabbed render queue with paged exports
Deploy / deploy (push) Successful in 3m32s

The queue page loaded up to 200 exports in one table with the Blender
renders appended underneath, so a Blender job sat below the whole export
history. Exports and Blender renders are now separate tabs; the Blender tab
label shows how many are active or failed so a render stays visible from
the exports tab.

Exports page 25 at a time. The summary cards now come from a server-side
count per status (listExports.statusCounts) instead of counting the loaded
rows, which was already wrong past 200, and clicking a card filters to it.
The project filter moves to the header since it applies to both tabs, and
the duplicated Deliveries button is gone.

Adds .claude/launch.json for running the dev server from the preview pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-18 05:04:41 +02:00
parent 708e8a0d8f
commit 532ebd82d7
3 changed files with 307 additions and 190 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+284 -187
View File
@@ -4,9 +4,10 @@ import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react";
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
@@ -55,35 +56,60 @@ interface Props {
initialStatus?: string;
}
const PAGE_SIZE = 25;
const BLENDER_ACTIVE = ["QUEUED", "CLAIMED", "RUNNING"];
export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) {
const { data: session } = useSession();
const { toast } = useToast();
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>(initialStatus);
const [page, setPage] = useState(1);
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["pipeline-queue", projectId, statusFilter],
queryKey: ["pipeline-queue", projectId, statusFilter, page],
queryFn: async () => {
const params = new URLSearchParams({ limit: "200" });
const params = new URLSearchParams({ limit: String(PAGE_SIZE), page: String(page) });
if (projectId !== "all") params.set("projectId", projectId);
if (statusFilter !== "all") params.set("status", statusFilter);
const res = await fetch(`/api/render/queue?${params}`);
if (!res.ok) throw new Error("Failed to load queue");
return res.json() as Promise<{ exports: QueueExport[]; pagination: { total: number } }>;
return res.json() as Promise<{
exports: QueueExport[];
pagination: { page: number; total: number; pages: number };
statusCounts: Record<string, number>;
}>;
},
refetchInterval: 5000,
// Keep the current page on screen while the next one loads, instead of
// flashing "Loading…" on every page turn.
placeholderData: (previous) => previous,
});
// Fetched here rather than in the tab so the tab label can say how many are
// running: a Blender render must not become invisible by sitting in a tab.
const { data: blenderData } = useQuery({
queryKey: ["blender-jobs", projectId],
queryFn: async () => {
const qs = projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : "";
const res = await fetch(`/api/render/blender${qs}`);
if (!res.ok) throw new Error("Failed to load Blender jobs");
return res.json() as Promise<{ jobs: BlenderJobRow[] }>;
},
refetchInterval: 5000,
});
const exports = data?.exports ?? [];
const counts = {
queued: exports.filter((e) => e.status === "QUEUED").length,
rendering: exports.filter((e) => e.status === "RENDERING").length,
failed: exports.filter((e) => FAILED_STATUSES.includes(e.status)).length,
readyForQc: exports.filter((e) => e.status === "READY_FOR_QC").length,
readyForDelivery: exports.filter((e) => e.status === "READY_FOR_DELIVERY").length,
};
const pagination = data?.pagination;
const statusCounts = data?.statusCounts ?? {};
const countOf = (statuses: string[]) => statuses.reduce((n, s) => n + (statusCounts[s] ?? 0), 0);
const totalExports = Object.values(statusCounts).reduce((n, c) => n + c, 0);
const blenderJobs = blenderData?.jobs ?? [];
const blenderActive = blenderJobs.filter((j) => BLENDER_ACTIVE.includes(j.status)).length;
const blenderFailed = blenderJobs.filter((j) => ["FAILED", "EXPIRED"].includes(j.status)).length;
async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") {
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
@@ -96,30 +122,56 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props)
queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] });
}
const cards: { label: string; value: number; className?: string }[] = [
{ label: "Queued", value: counts.queued },
{ label: "Rendering", value: counts.rendering, className: "text-blue-400" },
{ label: "Failed", value: counts.failed, className: "text-red-400" },
{ label: "Ready for QC", value: counts.readyForQc, className: "text-amber-400" },
{ label: "Ready for Delivery", value: counts.readyForDelivery, className: "text-green-400" },
const cards: { label: string; value: number; status?: string; className?: string }[] = [
{ label: "Queued", value: countOf(["QUEUED"]), status: "QUEUED" },
{ label: "Rendering", value: countOf(["RENDERING"]), status: "RENDERING", className: "text-blue-400" },
// Several statuses count as failed and the filter takes one, so this card
// is a figure only.
{ label: "Failed", value: countOf(FAILED_STATUSES), className: "text-red-400" },
{ label: "Ready for QC", value: countOf(["READY_FOR_QC"]), status: "READY_FOR_QC", className: "text-amber-400" },
{
label: "Ready for Delivery",
value: countOf(["READY_FOR_DELIVERY"]),
status: "READY_FOR_DELIVERY",
className: "text-green-400",
},
];
const changeProject = (id: string) => {
setProjectId(id);
setPage(1);
};
const changeStatus = (status: string) => {
setStatusFilter(status);
setPage(1);
};
const firstRow = pagination && pagination.total > 0 ? (pagination.page - 1) * PAGE_SIZE + 1 : 0;
const lastRow = pagination ? Math.min(pagination.page * PAGE_SIZE, pagination.total) : 0;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-semibold text-white">Render Queue</h1>
<p className="text-sm text-zinc-400 mt-1">
Exports queued from the AE panel — live status, retries and history
After Effects exports and Blender renders — live status, retries and history
</p>
</div>
<div className="flex items-center gap-2">
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
Deliveries
</Button>
</Link>
<div className="flex items-center gap-2 flex-wrap">
<Select value={projectId} onValueChange={changeProject}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
@@ -138,152 +190,206 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props)
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{cards.map((c) => (
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
</div>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
<SelectItem key={s} value={s}>
{statusLabel(s)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
<th className="px-4 py-2.5 font-medium">Shot</th>
<th className="px-4 py-2.5 font-medium">Version</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
<th className="px-4 py-2.5 font-medium">Machine</th>
<th className="px-4 py-2.5 font-medium">Attempt</th>
<th className="px-4 py-2.5 font-medium">Queued</th>
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
Loading…
</td>
</tr>
) : exports.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
No exports yet — queue one from the After Effects panel
</td>
</tr>
<Tabs defaultValue="exports" className="space-y-4">
<TabsList className="bg-zinc-900 border border-zinc-800">
<TabsTrigger value="exports">
After Effects exports
<span className="ml-2 text-xs text-zinc-500">{totalExports}</span>
</TabsTrigger>
<TabsTrigger value="blender">
Blender renders
{blenderActive > 0 ? (
<span className="ml-2 rounded-full bg-blue-500/20 px-1.5 text-xs text-blue-400">
{blenderActive} active
</span>
) : blenderFailed > 0 ? (
<span className="ml-2 rounded-full bg-red-500/20 px-1.5 text-xs text-red-400">
{blenderFailed} failed
</span>
) : (
exports.map((e) => (
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="px-4 py-2.5">
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
{e.shotCode}
</Link>
<div className="text-xs text-zinc-500">
{e.projectCode}
{e.episode ? ` · ep ${e.episode}` : ""}
</div>
</td>
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
<td className="px-4 py-2.5">
<span
className={cn(
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
title={e.job?.errorMessage ?? undefined}
>
{statusLabel(e.status)}
</span>
</td>
<td className="px-4 py-2.5">
{e.status === "RENDERING" && e.job ? (
<div className="space-y-1">
<Progress value={e.job.progress * 100} className="h-1.5" />
<div className="text-xs text-zinc-500">
{e.job.currentFrame != null && e.job.totalFrames != null
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
: ""}
ETA {formatEta(e.job.etaSeconds)}
</div>
</div>
) : (
<span className="text-zinc-600 text-xs">—</span>
)}
</td>
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
<td className="px-4 py-2.5 text-zinc-400">
{e.job ? `${e.job.attempt}` : "—"}
</td>
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{FAILED_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
<RotateCcw className="h-4 w-4 text-amber-400" />
</Button>
)}
{ACTIVE_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
<XCircle className="h-4 w-4 text-red-400" />
</Button>
)}
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
<Button
variant="ghost"
size="icon-sm"
title="Mark done manually (rendered outside the pipeline)"
onClick={() => action(e.id, "mark-done")}
>
<CheckCircle2 className="h-4 w-4 text-green-400" />
</Button>
)}
</div>
</td>
</tr>
))
<span className="ml-2 text-xs text-zinc-500">{blenderJobs.length}</span>
)}
</tbody>
</table>
</div>
</TabsTrigger>
</TabsList>
<BlenderQueue projectId={projectId} />
<TabsContent value="exports" className="space-y-4 mt-0">
{/* Summary cards — each one with a status filters the table to what it counts */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{cards.map((c) => {
const selected = c.status !== undefined && statusFilter === c.status;
const body = (
<>
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
</>
);
return c.status ? (
<button
key={c.label}
type="button"
onClick={() => changeStatus(selected ? "all" : c.status!)}
className={cn(
"rounded-lg border bg-zinc-900 px-4 py-3 text-left transition-colors hover:border-zinc-600",
selected ? "border-zinc-500" : "border-zinc-800"
)}
>
{body}
</button>
) : (
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
{body}
</div>
);
})}
</div>
<Select value={statusFilter} onValueChange={changeStatus}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
<SelectItem key={s} value={s}>
{statusLabel(s)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
<th className="px-4 py-2.5 font-medium">Shot</th>
<th className="px-4 py-2.5 font-medium">Version</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
<th className="px-4 py-2.5 font-medium">Machine</th>
<th className="px-4 py-2.5 font-medium">Attempt</th>
<th className="px-4 py-2.5 font-medium">Queued</th>
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
Loading…
</td>
</tr>
) : exports.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
{statusFilter === "all"
? "No exports yet — queue one from the After Effects panel"
: `No exports are ${statusLabel(statusFilter).toLowerCase()}`}
</td>
</tr>
) : (
exports.map((e) => (
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="px-4 py-2.5">
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
{e.shotCode}
</Link>
<div className="text-xs text-zinc-500">
{e.projectCode}
{e.episode ? ` · ep ${e.episode}` : ""}
</div>
</td>
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
<td className="px-4 py-2.5">
<span
className={cn(
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
title={e.job?.errorMessage ?? undefined}
>
{statusLabel(e.status)}
</span>
</td>
<td className="px-4 py-2.5">
{e.status === "RENDERING" && e.job ? (
<div className="space-y-1">
<Progress value={e.job.progress * 100} className="h-1.5" />
<div className="text-xs text-zinc-500">
{e.job.currentFrame != null && e.job.totalFrames != null
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
: ""}
ETA {formatEta(e.job.etaSeconds)}
</div>
</div>
) : (
<span className="text-zinc-600 text-xs">—</span>
)}
</td>
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
<td className="px-4 py-2.5 text-zinc-400">{e.job ? `${e.job.attempt}` : "—"}</td>
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{FAILED_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
<RotateCcw className="h-4 w-4 text-amber-400" />
</Button>
)}
{ACTIVE_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
<XCircle className="h-4 w-4 text-red-400" />
</Button>
)}
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
<Button
variant="ghost"
size="icon-sm"
title="Mark done manually (rendered outside the pipeline)"
onClick={() => action(e.id, "mark-done")}
>
<CheckCircle2 className="h-4 w-4 text-green-400" />
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{pagination && pagination.pages > 1 && (
<div className="flex items-center justify-between text-sm text-zinc-400">
<span>
{firstRow}–{lastRow} of {pagination.total}
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs">
Page {pagination.page} of {pagination.pages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= pagination.pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</TabsContent>
<TabsContent value="blender" className="mt-0">
<BlenderQueue jobs={blenderJobs} loaded={blenderData !== undefined} />
</TabsContent>
</Tabs>
</div>
);
}
@@ -320,24 +426,13 @@ const BLENDER_STATUS_STYLES: Record<string, string> = {
/**
* Standalone Blender renders. They have no Export behind them, so they cannot
* appear in the table above — but they share the queue, and a render nobody can
* see is a render nobody can retry.
* appear in the exports table — but they share the queue, and a render nobody
* can see is a render nobody can retry.
*/
function BlenderQueue({ projectId }: { projectId: string }) {
function BlenderQueue({ jobs, loaded }: { jobs: BlenderJobRow[]; loaded: boolean }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ["blender-jobs", projectId],
queryFn: async () => {
const qs = projectId && projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : "";
const res = await fetch(`/api/render/blender${qs}`);
if (!res.ok) throw new Error("Failed to load Blender jobs");
return res.json() as Promise<{ jobs: BlenderJobRow[] }>;
},
refetchInterval: 5000,
});
async function jobAction(id: string, verb: "cancel" | "retry") {
const res = await fetch(`/api/render/blender/${id}?action=${verb}`, { method: "POST" });
if (!res.ok) {
@@ -347,14 +442,16 @@ function BlenderQueue({ projectId }: { projectId: string }) {
queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
}
const jobs = data?.jobs ?? [];
if (jobs.length === 0) return null;
if (jobs.length === 0) {
return (
<div className="rounded-lg border border-zinc-800 px-4 py-10 text-center text-sm text-zinc-500">
{loaded ? "No Blender renders yet — submit one from the Blender panel" : "Loading…"}
</div>
);
}
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Blender renders
</div>
<div className="divide-y divide-zinc-800/60">
{jobs.map((j) => (
<div key={j.id} className="px-4 py-3 text-sm">
+12 -3
View File
@@ -515,14 +515,20 @@ export interface ListExportsFilter {
export async function listExports(filter: ListExportsFilter) {
const page = Math.max(1, filter.page ?? 1);
const limit = Math.min(200, Math.max(1, filter.limit ?? 50));
const where: Prisma.ExportWhereInput = {
const scope: Prisma.ExportWhereInput = {
...(filter.projectId ? { projectId: filter.projectId } : {}),
...(filter.status ? { status: filter.status } : {}),
...(filter.shotId ? { shotId: filter.shotId } : {}),
...(filter.episode ? { shot: { episode: filter.episode } } : {}),
};
const [total, rows] = await Promise.all([
const where: Prisma.ExportWhereInput = {
...scope,
...(filter.status ? { status: filter.status } : {}),
};
const [total, byStatus, rows] = await Promise.all([
db.export.count({ where }),
// Counted across the whole scope, ignoring the status filter and paging:
// summary figures taken from the loaded page would count only that page.
db.export.groupBy({ by: ["status"], where: scope, _count: { _all: true } }),
db.export.findMany({
where,
orderBy: { createdAt: "desc" },
@@ -540,6 +546,9 @@ export async function listExports(filter: ListExportsFilter) {
]);
return {
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
statusCounts: Object.fromEntries(byStatus.map((g) => [g.status, g._count._all])) as Partial<
Record<ExportStatus, number>
>,
exports: rows.map((e) => ({
id: e.id,
shotCode: e.shot.shotCode,