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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user