Files
vfxreview/app/(dashboard)/pipeline/PipelineQueueClient.tsx
T
twotalesanimation 532ebd82d7
Deploy / deploy (push) Successful in 3m32s
feat(pipeline): tabbed render queue with paged exports
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>
2026-09-18 05:04:41 +02:00

503 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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, 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,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import {
EXPORT_STATUS_STYLES,
FAILED_STATUSES,
ACTIVE_STATUSES,
statusLabel,
formatEta,
} from "./status-colors";
interface QueueExport {
id: string;
shotCode: string;
episode: string | null;
projectName: string;
projectCode: string;
versionString: string;
status: string;
statusChangedAt: string;
createdAt: string;
outputDir: string;
job: {
id: string;
attempt: number;
status: string;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
etaSeconds: number | null;
priority: number;
machineName: string | null;
errorMessage: string | null;
} | null;
}
interface Props {
projects: { id: string; name: string; code: string }[];
/** Status to open filtered to, from `?status=` (see page.tsx). */
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, page],
queryFn: async () => {
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: { 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 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" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
} else {
toast({ title: `Export ${verb === "mark-done" ? "marked done" : verb === "retry" ? "requeued" : "cancelled"}` });
}
queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] });
}
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">
After Effects exports and Blender renders — live status, retries and history
</p>
</div>
<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" />
Deliveries
</Button>
</Link>
<Link href="/pipeline/machines">
<Button variant="outline" size="sm">
<Server className="h-4 w-4 mr-2" />
Machines
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
</div>
</div>
<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>
) : (
<span className="ml-2 text-xs text-zinc-500">{blenderJobs.length}</span>
)}
</TabsTrigger>
</TabsList>
<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>
);
}
interface BlenderJobRow {
id: string;
label: string | null;
status: string;
attempt: number;
maxAttempts: number;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
etaSeconds: number | null;
errorMessage: string | null;
machineName: string | null;
submittedBy: string | null;
projectCode: string | null;
blendFile: string | null;
frameStart: number | null;
frameEnd: number | null;
createdAt: string;
}
const BLENDER_STATUS_STYLES: Record<string, string> = {
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
CLAIMED: "bg-blue-500/15 text-blue-400 border-blue-500/30",
RUNNING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
COMPLETED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
EXPIRED: "bg-red-500/15 text-red-400 border-red-500/30",
};
/**
* Standalone Blender renders. They have no Export behind them, so they cannot
* appear in the exports table — but they share the queue, and a render nobody
* can see is a render nobody can retry.
*/
function BlenderQueue({ jobs, loaded }: { jobs: BlenderJobRow[]; loaded: boolean }) {
const { toast } = useToast();
const queryClient = useQueryClient();
async function jobAction(id: string, verb: "cancel" | "retry") {
const res = await fetch(`/api/render/blender/${id}?action=${verb}`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
}
queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
}
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="divide-y divide-zinc-800/60">
{jobs.map((j) => (
<div key={j.id} className="px-4 py-3 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-zinc-200">{j.label ?? "(untitled)"}</span>
<span className={cn("rounded-full border px-2 py-0.5 text-xs", BLENDER_STATUS_STYLES[j.status] ?? "")}>
{j.status.toLowerCase()}
</span>
{j.projectCode && <span className="text-xs text-zinc-500">{j.projectCode}</span>}
{j.frameStart != null && (
<span className="text-xs text-zinc-500">
frames {j.frameStart}–{j.frameEnd}
</span>
)}
{j.status === "RUNNING" && (
<span className="text-xs text-blue-400">
{Math.round(j.progress * 100)}%
{j.currentFrame != null ? ` · frame ${j.currentFrame}` : ""}
{j.etaSeconds ? ` · ETA ${formatEta(j.etaSeconds)}` : ""}
</span>
)}
{j.machineName && <span className="text-xs text-zinc-500">{j.machineName}</span>}
{j.attempt > 1 && (
<span className="text-xs text-zinc-500">
attempt {j.attempt}/{j.maxAttempts}
</span>
)}
<span className="ml-auto flex items-center gap-1">
{["QUEUED", "CLAIMED", "RUNNING"].includes(j.status) && (
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => jobAction(j.id, "cancel")}>
<XCircle className="h-4 w-4 text-red-400" />
</Button>
)}
{["FAILED", "CANCELLED", "EXPIRED"].includes(j.status) && (
<Button variant="ghost" size="icon-sm" title="Requeue" onClick={() => jobAction(j.id, "retry")}>
<RotateCcw className="h-4 w-4 text-amber-400" />
</Button>
)}
</span>
</div>
{j.blendFile && <div className="mt-1 text-xs text-zinc-600 break-all">{j.blendFile}</div>}
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
</div>
))}
</div>
</div>
);
}