feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot headlessly with the studio slate/overlay template to produce the delivery MOV and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage. Server: - New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus Project.deliveryConfig and per-submission slate fields (Export.vfxScope, Export.submissionNote, inherited from the shot's previous export). Both migrations are purely additive; no existing column is touched. - lib/render-pipeline: server-enforced state machine, transactional version increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by machine availability windows, and a lease reaper run from instrumentation.ts. - /api/ext/* endpoints for the panel and workers; session-auth mirrors under /api/render and /api/machines for the web UI. - Pipeline pages: render queue, export detail, machine monitoring, plus an Exports tab on shot detail. RenderWorker (.NET 8 Windows service, new): - Registration, heartbeat as cancel channel, claim loop, aerender runner with progress parsing and stall watchdog, crash recovery and disk-spooled reporting that survives server downtime. - Preview stage: headless AE assembles the preview comp into a throwaway AEP with both output modules queued, then a single aerender pass renders them. Preview jobs are not claimed while an interactive AE session is open, so an artist's project is never taken over. AE panel: Queue Export with live status polling, urgent flag, retry, and the VFX Scope / Submission Note fields. Every existing panel action is unchanged. Preview chaining ships disabled behind SystemConfig preview.enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"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 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
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 }[];
|
||||
}
|
||||
|
||||
export function PipelineQueueClient({ projects }: Props) {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [projectId, setProjectId] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["pipeline-queue", projectId, statusFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
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 } }>;
|
||||
},
|
||||
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,
|
||||
};
|
||||
|
||||
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; 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" },
|
||||
];
|
||||
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, RotateCcw, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
EXPORT_STATUS_STYLES,
|
||||
FAILED_STATUSES,
|
||||
ACTIVE_STATUSES,
|
||||
statusLabel,
|
||||
} from "../../status-colors";
|
||||
|
||||
interface ExportDetail {
|
||||
export: {
|
||||
id: string;
|
||||
status: string;
|
||||
versionString: string;
|
||||
statusChangedAt: string;
|
||||
createdAt: string;
|
||||
aepPath: string;
|
||||
compName: string;
|
||||
outputDir: string;
|
||||
outputPattern: string;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
colorspace: string | null;
|
||||
exrFileCount: number | null;
|
||||
exrTotalBytes: number | null;
|
||||
vfxScope: string | null;
|
||||
submissionNote: string | null;
|
||||
submittedByName: string | null;
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
episode: string | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
};
|
||||
renderJobs: {
|
||||
id: string;
|
||||
attempt: number;
|
||||
status: string;
|
||||
priority: number;
|
||||
progress: number;
|
||||
currentFrame: number | null;
|
||||
totalFrames: number | null;
|
||||
errorMessage: string | null;
|
||||
logTail: string | null;
|
||||
exitCode: number | null;
|
||||
claimedAt: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
machine: { id: string; name: string } | null;
|
||||
}[];
|
||||
events: {
|
||||
id: string;
|
||||
fromStatus: string | null;
|
||||
toStatus: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["pipeline-export", exportId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/render/exports/${exportId}`);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load export");
|
||||
return res.json() as Promise<ExportDetail>;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
async function action(verb: "retry" | "cancel") {
|
||||
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" });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="p-6 text-red-400">
|
||||
{(error as Error)?.message ?? "Export not found"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const e = data.export;
|
||||
const manifestRows: [string, string][] = [
|
||||
["Project", `${e.shot.project.name} (${e.shot.project.code})`],
|
||||
["Shot", e.shot.shotCode],
|
||||
["Comp", e.compName],
|
||||
["AEP", e.aepPath],
|
||||
["Output dir", e.outputDir],
|
||||
["Pattern", e.outputPattern],
|
||||
["Frames", `${e.frameStart}–${e.frameEnd} @ ${e.fps} fps`],
|
||||
["Resolution", `${e.width}×${e.height}`],
|
||||
["Colorspace", e.colorspace ?? "—"],
|
||||
["EXR files", e.exrFileCount != null ? String(e.exrFileCount) : "—"],
|
||||
["Submitted by", e.submittedByName ?? "—"],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">
|
||||
{e.shot.shotCode} · {e.versionString}
|
||||
</h1>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block mt-1 rounded-full border px-2 py-0.5 text-xs",
|
||||
EXPORT_STATUS_STYLES[e.status] ?? ""
|
||||
)}
|
||||
>
|
||||
{statusLabel(e.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{FAILED_STATUSES.includes(e.status) && (
|
||||
<Button variant="outline" size="sm" onClick={() => action("retry")}>
|
||||
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
|
||||
</Button>
|
||||
)}
|
||||
{ACTIVE_STATUSES.includes(e.status) && (
|
||||
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manifest */}
|
||||
<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">Manifest</div>
|
||||
<dl className="grid sm:grid-cols-2 gap-x-8 gap-y-2 p-4 text-sm">
|
||||
{manifestRows.map(([k, v]) => (
|
||||
<div key={k} className="flex gap-3">
|
||||
<dt className="w-28 shrink-0 text-zinc-500">{k}</dt>
|
||||
<dd className="text-zinc-300 break-all">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Per-submission slate fields */}
|
||||
{(e.vfxScope || e.submissionNote) && (
|
||||
<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">
|
||||
Submission
|
||||
</div>
|
||||
<dl className="p-4 space-y-3 text-sm">
|
||||
{e.vfxScope && (
|
||||
<div>
|
||||
<dt className="text-zinc-500 mb-0.5">VFX Scope</dt>
|
||||
<dd className="text-zinc-300 whitespace-pre-wrap">{e.vfxScope}</dd>
|
||||
</div>
|
||||
)}
|
||||
{e.submissionNote && (
|
||||
<div>
|
||||
<dt className="text-zinc-500 mb-0.5">Submission Note</dt>
|
||||
<dd className="text-zinc-300 whitespace-pre-wrap">{e.submissionNote}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render attempts */}
|
||||
<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">
|
||||
Render attempts
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{e.renderJobs.map((j) => (
|
||||
<div key={j.id} className="p-4 text-sm">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-white font-medium">Attempt {j.attempt}</span>
|
||||
<span className="text-xs text-zinc-400">{j.status}</span>
|
||||
<span className="text-xs text-zinc-500">{j.machine?.name ?? "unclaimed"}</span>
|
||||
{j.exitCode != null && <span className="text-xs text-zinc-500">exit {j.exitCode}</span>}
|
||||
{j.startedAt && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
started {new Date(j.startedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{j.finishedAt && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
finished {new Date(j.finishedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
|
||||
{j.logTail && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
|
||||
Log tail
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-3 text-xs text-zinc-400 whitespace-pre-wrap">
|
||||
{j.logTail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{e.renderJobs.length === 0 && (
|
||||
<div className="p-4 text-sm text-zinc-500">No attempts yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event timeline */}
|
||||
<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">Timeline</div>
|
||||
<ol className="p-4 space-y-2">
|
||||
{e.events.map((ev) => (
|
||||
<li key={ev.id} className="flex items-start gap-3 text-sm">
|
||||
<span className="text-xs text-zinc-500 w-40 shrink-0 whitespace-nowrap">
|
||||
{new Date(ev.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-zinc-300">
|
||||
{ev.fromStatus ? `${statusLabel(ev.fromStatus)} → ` : ""}
|
||||
{statusLabel(ev.toStatus)}
|
||||
<span className="text-zinc-500"> · {ev.actorType.toLowerCase()}</span>
|
||||
{ev.note && <span className="block text-xs text-zinc-500">{ev.note}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ExportDetailClient } from "./ExportDetailClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ExportDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ exportId: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
const { exportId } = await params;
|
||||
return <ExportDetailClient exportId={exportId} />;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { ArrowLeft, Zap, Power, Cpu, HardDrive, MemoryStick } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MachineRow {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
enabled: boolean;
|
||||
status: "ONLINE" | "OFFLINE" | "DISABLED";
|
||||
lastSeenAt: string | null;
|
||||
workerVersion: string | null;
|
||||
aeVersion: string | null;
|
||||
availability: { mode?: string } | null;
|
||||
renderNowUntil: string | null;
|
||||
latestHeartbeat: {
|
||||
createdAt: string;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskFreeGb: number | null;
|
||||
} | null;
|
||||
currentJob: {
|
||||
id: string;
|
||||
exportId: string | null;
|
||||
shotCode: string | null;
|
||||
versionString: string | null;
|
||||
progress: number;
|
||||
etaSeconds: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
ONLINE: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
OFFLINE: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
DISABLED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
export function MachinesClient() {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["pipeline-machines"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/machines");
|
||||
if (!res.ok) throw new Error("Failed to load machines");
|
||||
return res.json() as Promise<{ machines: MachineRow[] }>;
|
||||
},
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
async function patch(machineId: string, body: Record<string, unknown>, okMsg: string) {
|
||||
const res = await fetch(`/api/machines/${machineId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const resBody = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: "Update failed", description: resBody.error ?? res.statusText, variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: okMsg });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-machines"] });
|
||||
}
|
||||
|
||||
const machines = data?.machines ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Render Machines</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
Worker status, render windows and the Render Now override
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-zinc-500">Loading…</div>
|
||||
) : machines.length === 0 ? (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-10 text-center text-zinc-500">
|
||||
No machines registered yet — install the RenderWorker service on a workstation and it will
|
||||
appear here after its first registration.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{machines.map((m) => {
|
||||
const renderNowActive = m.renderNowUntil && new Date(m.renderNowUntil) > new Date();
|
||||
return (
|
||||
<div key={m.id} className="rounded-lg border border-zinc-800 bg-zinc-900 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-medium">{m.name}</div>
|
||||
<div className="text-xs text-zinc-500">{m.hostname}</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn("rounded-full border px-2 py-0.5 text-xs", STATUS_STYLES[m.status])}
|
||||
>
|
||||
{m.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500 space-y-0.5">
|
||||
<div>Worker {m.workerVersion ?? "?"} · AE {m.aeVersion ?? "?"}</div>
|
||||
<div>
|
||||
Availability: {m.availability?.mode ?? "ALWAYS"}
|
||||
{renderNowActive && (
|
||||
<span className="text-amber-400">
|
||||
{" "}· Render Now until {new Date(m.renderNowUntil!).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.lastSeenAt && <div>Last seen {new Date(m.lastSeenAt).toLocaleString()}</div>}
|
||||
</div>
|
||||
|
||||
{m.latestHeartbeat && (
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
{m.latestHeartbeat.cpuPercent != null ? `${Math.round(m.latestHeartbeat.cpuPercent)}%` : "—"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MemoryStick className="h-3.5 w-3.5" />
|
||||
{m.latestHeartbeat.memPercent != null ? `${Math.round(m.latestHeartbeat.memPercent)}%` : "—"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
{m.latestHeartbeat.diskFreeGb != null ? `${Math.round(m.latestHeartbeat.diskFreeGb)} GB free` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{m.currentJob ? (
|
||||
<div className="rounded bg-zinc-800/60 p-2.5 space-y-1.5">
|
||||
<div className="text-xs text-zinc-300">
|
||||
Rendering{" "}
|
||||
{m.currentJob.exportId ? (
|
||||
<Link
|
||||
href={`/pipeline/exports/${m.currentJob.exportId}`}
|
||||
className="text-amber-400 hover:underline"
|
||||
>
|
||||
{m.currentJob.shotCode} {m.currentJob.versionString}
|
||||
</Link>
|
||||
) : (
|
||||
"job"
|
||||
)}
|
||||
</div>
|
||||
<Progress value={m.currentJob.progress * 100} className="h-1.5" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-zinc-600">Idle</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!m.enabled}
|
||||
onClick={() =>
|
||||
patch(
|
||||
m.id,
|
||||
{ renderNowHours: renderNowActive ? 0 : 4 },
|
||||
renderNowActive ? "Render Now cleared" : "Render Now active for 4 h"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Zap className={cn("h-4 w-4 mr-1.5", renderNowActive ? "text-amber-400" : "")} />
|
||||
{renderNowActive ? "Stop Render Now" : "Render Now (4 h)"}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
patch(m.id, { enabled: !m.enabled }, m.enabled ? "Machine disabled" : "Machine enabled")
|
||||
}
|
||||
>
|
||||
<Power className={cn("h-4 w-4 mr-1.5", m.enabled ? "text-red-400" : "text-green-400")} />
|
||||
{m.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { MachinesClient } from "./MachinesClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MachinesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
return <MachinesClient />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { PipelineQueueClient } from "./PipelineQueueClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PipelinePage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
select: { id: true, name: true, code: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return <PipelineQueueClient projects={projects} />;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Shared status→style maps for the pipeline pages. */
|
||||
|
||||
export const EXPORT_STATUS_STYLES: Record<string, string> = {
|
||||
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
RENDERING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
RENDER_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
VALIDATING: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
||||
VALIDATION_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
GENERATING_PREVIEW: "bg-indigo-500/15 text-indigo-400 border-indigo-500/30",
|
||||
PREVIEW_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
READY_FOR_QC: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
QC_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
READY_FOR_DELIVERY: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
PACKAGED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
|
||||
SUPERSEDED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
|
||||
ARCHIVED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
|
||||
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
|
||||
};
|
||||
|
||||
export const FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"];
|
||||
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
return status.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number | null | undefined): string {
|
||||
if (seconds == null || seconds <= 0) return "—";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
Reference in New Issue
Block a user