Files
twotalesanimation f6a7f0ef5a feat(dashboard): show what is blocked and how the farm is doing
The dashboard was a generic task-manager view of a studio that does not work
that way. Two of its five headline numbers counted Task.dueDate, which no
pipeline code writes - the only writer is a manual field on the task detail
page, so those cards read zero by construction. The rest were unscoped and
cumulative ("approved" across every project ever finished), the activity feed
was the notification table, and the shot queue sampled 15 rows by updatedAt,
a column the farm touches constantly. Nothing about failed exports, QC depth
or the render farm appeared at all, so a RENDER_FAILED was only visible if
someone thought to open the queue and filter for it.

Replaced with pipeline state, which the system maintains on its own:

- Blocked queue: the four failed statuses plus exports that stopped
  progressing, each with the reason read off the actual cause - the failing
  validation check, the QC note, the aerender error, or the machine that
  claimed it and went quiet.
- Farm strip: machines, queue depth by job type, oldest unclaimed job, and
  worker-version drift, since a worker's version decides which fixes it has.
- Five counters that can go down, scoped to live productions, each linking to
  the rows it counted.

The queue page ignored ?status=, so the counters had nowhere to link; it now
takes an initial filter from the URL. Stat cards are hidden from CLIENT users
- the old ones were generic version counts, these are internal pipeline state.

Thresholds live in SystemConfig (dashboard.qcStaleHours, .stalledJobHours) so
they can be tuned without a redeploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:40:40 +02:00

111 lines
4.7 KiB
TypeScript

import Link from "next/link";
import { formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import { EXPORT_STATUS_STYLES, statusLabel } from "@/app/(dashboard)/pipeline/status-colors";
import type { BlockedExport } from "@/lib/dashboard/ops";
/**
* The exception list: exports that have stopped moving and need a person.
*
* Read by scanning down the left edge — the severity rail says which rows are
* dead in the water (a failed state, which nothing retries) versus merely
* overdue (still nominally in flight, or waiting on a QC pass). The reason
* column exists so the answer to "what happened" doesn't require opening
* anything.
*/
export function BlockedQueue({ blocked }: { blocked: BlockedExport[] }) {
const critical = blocked.filter((b) => b.severity === "critical").length;
return (
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
<h3 className="text-sm font-semibold text-white">
{blocked.length === 0
? "Nothing blocked"
: `${blocked.length} export${blocked.length === 1 ? "" : "s"} stalled`}
{critical > 0 && (
<span className="ml-2 text-xs font-normal text-red-400">{critical} failed</span>
)}
</h3>
<Link href="/pipeline" className="text-xs text-zinc-400 hover:text-amber-400">
Open render queue →
</Link>
</div>
{blocked.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-zinc-500">
Every export is either moving or done.
</p>
) : (
<div className="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="w-[3px] p-0" />
<th className="px-4 py-2.5 font-medium">Shot</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium whitespace-nowrap">Stuck</th>
<th className="px-4 py-2.5 font-medium">Why</th>
<th className="px-4 py-2.5 font-medium">Artist</th>
</tr>
</thead>
<tbody>
{blocked.map((b) => (
<tr key={b.exportId} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="w-[3px] p-0">
<div
className={cn(
"h-9 w-[3px]",
b.severity === "critical" ? "bg-red-500" : "bg-amber-500"
)}
/>
</td>
<td className="px-4 py-2.5">
<Link
href={`/pipeline/exports/${b.exportId}`}
className="font-mono text-[13px] font-medium text-white hover:text-amber-400"
>
{b.shotCode}{" "}
<span className="text-zinc-500">{b.versionString}</span>
</Link>
<div className="text-xs text-zinc-500">
{b.projectCode}
{b.episode ? ` · ep ${b.episode}` : ""}
</div>
</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[b.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
>
{statusLabel(b.status)}
</span>
</td>
<td
className={cn(
"px-4 py-2.5 tabular-nums whitespace-nowrap",
b.severity === "critical" ? "text-red-400" : "text-zinc-300"
)}
>
{formatDistanceToNowStrict(b.since)}
</td>
{/* The reason can be an aerender error several lines long;
it is clamped here and given in full on hover. */}
<td className="px-4 py-2.5 text-zinc-400 max-w-md" title={b.reason}>
<span className="line-clamp-2">{b.reason}</span>
</td>
<td className="px-4 py-2.5 text-zinc-300 whitespace-nowrap">
{b.artistName ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}