Files
vfxreview/app/(dashboard)/pipeline/PipelineQueueClient.tsx
T
twotalesanimation 5e29c3b88d
Deploy / deploy (push) Successful in 3m16s
feat(pipeline): search the render queue by shot or client code
A search box above the exports table matches any part of the shot code or
client code, case-insensitively, so "UNG_114" lists a whole episode. It is
applied server-side (the table is paged there), debounced, and scopes the
summary counts too; combines with the project and status filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:51:04 +02:00

544 lines
23 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 { useEffect, 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, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
// Searched on the server (the table is paged there), so typing is debounced
// rather than firing a request per keystroke.
useEffect(() => {
const next = searchInput.trim();
if (next === search) return;
const t = setTimeout(() => {
setSearch(next);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [searchInput, search]);
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["pipeline-queue", projectId, statusFilter, search, 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);
if (search) params.set("q", search);
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-4 md: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>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setSearchInput("")}
placeholder="Search shots, e.g. UNG_114"
aria-label="Search exports by shot code"
className="pl-8 pr-8"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</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>
<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">
{search
? `No exports match “${search}”`
: 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>
);
}