diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx
index 2a5a4be..05eca0d 100644
--- a/app/(dashboard)/dashboard/page.tsx
+++ b/app/(dashboard)/dashboard/page.tsx
@@ -1,58 +1,43 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { StatsCards } from "@/components/dashboard/StatsCards";
+import { BlockedQueue } from "@/components/dashboard/BlockedQueue";
+import { FarmStrip } from "@/components/dashboard/FarmStrip";
import { ShotQueue } from "@/components/dashboard/ShotQueue";
-import { RecentActivity } from "@/components/dashboard/RecentActivity";
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
+import { getBlockedExports, getFarmStatus, getOpsCounts } from "@/lib/dashboard/ops";
import type { DashboardStats } from "@/types";
export const metadata = { title: "Dashboard" };
+// Queue depth and machine state are stale the moment they are cached.
+export const dynamic = "force-dynamic";
+
+/**
+ * The dashboard answers one question: can we ship what is due, and what is
+ * stopping us? Everything on it is pipeline state — exports, QC, deliveries,
+ * worker heartbeats — rather than anything a person has to maintain by hand.
+ */
async function getDashboardData(userId: string, role: string) {
const isArtist = role === "ARTIST";
const isClient = role === "CLIENT";
- const now = new Date();
- const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- const todayEnd = new Date(todayStart.getTime() + 86400000);
- const [awaitingReview, needsRevisions, approved, activeProjects,
- tasksDueToday, tasksInReview, tasksOverdue, myTasksCount] =
- await Promise.all([
- db.version.count({ where: { approvalStatus: "PENDING_REVIEW" } }),
- db.version.count({ where: { approvalStatus: "NEEDS_CHANGES" } }),
- db.version.count({ where: { approvalStatus: "APPROVED" } }),
- db.project.count({ where: { status: "ACTIVE" } }),
- db.task.count({
- where: {
- dueDate: { gte: todayStart, lt: todayEnd },
- status: { not: "DONE" },
- ...(isArtist ? { assignedArtistId: userId } : {}),
- },
- }),
- db.task.count({
- where: {
- status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] },
- ...(isArtist ? { assignedArtistId: userId } : {}),
- },
- }),
- db.task.count({
- where: {
- dueDate: { lt: todayStart },
- status: { not: "DONE" },
- ...(isArtist ? { assignedArtistId: userId } : {}),
- },
- }),
- db.task.count({
- where: { assignedArtistId: userId, status: { not: "DONE" } },
- }),
- ]);
+ const [counts, blocked, farm] = await Promise.all([
+ getOpsCounts(),
+ getBlockedExports(),
+ getFarmStatus(),
+ ]);
- const shotsWhere = isArtist ? { artistId: userId } : {};
+ // An artist's own shots come first; everyone else sees the floor.
const shots = await db.shot.findMany({
- where: { ...shotsWhere, status: { not: "COMPLETE" } },
+ where: {
+ ...(isArtist ? { artistId: userId } : {}),
+ status: { not: "COMPLETE" },
+ project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
+ },
take: 15,
- orderBy: { updatedAt: "desc" },
+ orderBy: [{ dueDate: { sort: "asc", nulls: "last" } }, { updatedAt: "desc" }],
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
@@ -63,12 +48,8 @@ async function getDashboardData(userId: string, role: string) {
},
});
- // My tasks (for widget)
const myTasks = await db.task.findMany({
- where: {
- assignedArtistId: userId,
- status: { not: "DONE" },
- },
+ where: { assignedArtistId: userId, status: { not: "DONE" } },
take: 8,
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
include: {
@@ -78,7 +59,6 @@ async function getDashboardData(userId: string, role: string) {
},
});
- // Tasks in review (for supervisors/producers)
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
take: 8,
@@ -91,14 +71,8 @@ async function getDashboardData(userId: string, role: string) {
},
});
- const activity = await db.notification.findMany({
- where: isClient ? { userId } : {},
- take: 20,
- orderBy: { createdAt: "desc" },
- include: { user: { select: { name: true, image: true } } },
- });
-
- // Schedule data (for producers/supervisors/admins)
+ // Schedule overview (producers/supervisors/admins) — unchanged; it reads the
+ // schedule page's own data rather than anything the pipeline owns.
const scheduledTasks =
!isArtist && !isClient
? await db.task.findMany({
@@ -119,43 +93,24 @@ async function getDashboardData(userId: string, role: string) {
!isArtist && !isClient
? await db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
- select: {
- id: true,
- name: true,
- email: true,
- image: true,
- role: true,
- },
+ select: { id: true, name: true, email: true, image: true, role: true },
orderBy: { name: "asc" },
})
: [];
- const stats: DashboardStats = {
- awaitingReview,
- needsRevisions,
- approved,
- overdue: tasksOverdue,
- activeProjects,
- tasksDueToday,
- tasksInReview,
- tasksOverdue,
- myTasksCount,
- };
+ const stats: DashboardStats = counts;
- return { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
+ return { stats, blocked, farm, shots, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) return null;
- const { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
- await getDashboardData(session.user.id, session.user.role);
-
const isClient = session.user.role === "CLIENT";
- const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
- session.user.role
- );
+ const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
+ const { stats, blocked, farm, shots, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
+ await getDashboardData(session.user.id, session.user.role);
return (
@@ -166,7 +121,25 @@ export default async function DashboardPage() {
-
+ {!isClient && }
+
+ {!isClient && (
+
+
+
+ Blocked — needs a human
+
+
+
+
+
+
+ Render farm
+
+
+
+
+ )}
{!isClient && (
Schedule Overview
-
+
)}
-
-
-
-
-
- Recent Activity
-
-
-
-
+
+
+ {session.user.role === "ARTIST" ? "My shots" : "Shots in flight"}
+
+
+
diff --git a/app/(dashboard)/pipeline/PipelineQueueClient.tsx b/app/(dashboard)/pipeline/PipelineQueueClient.tsx
index 5a03a8b..13111b4 100644
--- a/app/(dashboard)/pipeline/PipelineQueueClient.tsx
+++ b/app/(dashboard)/pipeline/PipelineQueueClient.tsx
@@ -51,14 +51,16 @@ interface QueueExport {
interface Props {
projects: { id: string; name: string; code: string }[];
+ /** Status to open filtered to, from `?status=` (see page.tsx). */
+ initialStatus?: string;
}
-export function PipelineQueueClient({ projects }: Props) {
+export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) {
const { data: session } = useSession();
const { toast } = useToast();
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState
("all");
- const [statusFilter, setStatusFilter] = useState("all");
+ const [statusFilter, setStatusFilter] = useState(initialStatus);
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
const { data, isLoading, refetch, isFetching } = useQuery({
diff --git a/app/(dashboard)/pipeline/page.tsx b/app/(dashboard)/pipeline/page.tsx
index 4ea24e6..e37af09 100644
--- a/app/(dashboard)/pipeline/page.tsx
+++ b/app/(dashboard)/pipeline/page.tsx
@@ -1,20 +1,32 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
+import { ExportStatus } from "@prisma/client";
import { db } from "@/lib/db";
import { PipelineQueueClient } from "./PipelineQueueClient";
export const dynamic = "force-dynamic";
-export default async function PipelinePage() {
+export default async function PipelinePage({
+ searchParams,
+}: {
+ searchParams: Promise<{ status?: string }>;
+}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
+ // Lets other pages link straight to a filtered queue — the dashboard's "In
+ // QC" figure is only useful if it leads to the rows it counted. Anything
+ // that is not a real status falls back to the unfiltered queue.
+ const requested = (await searchParams).status;
+ const initialStatus =
+ requested && (Object.values(ExportStatus) as string[]).includes(requested) ? requested : "all";
+
const projects = await db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
});
- return ;
+ return ;
}
diff --git a/components/dashboard/BlockedQueue.tsx b/components/dashboard/BlockedQueue.tsx
new file mode 100644
index 0000000..3d5ee8d
--- /dev/null
+++ b/components/dashboard/BlockedQueue.tsx
@@ -0,0 +1,110 @@
+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 (
+
+
+
+ {blocked.length === 0
+ ? "Nothing blocked"
+ : `${blocked.length} export${blocked.length === 1 ? "" : "s"} stalled`}
+ {critical > 0 && (
+ {critical} failed
+ )}
+
+
+ Open render queue →
+
+
+
+ {blocked.length === 0 ? (
+
+ Every export is either moving or done.
+
+ ) : (
+
+
+
+
+
+ Shot
+ Status
+ Stuck
+ Why
+ Artist
+
+
+
+ {blocked.map((b) => (
+
+
+
+
+
+
+ {b.shotCode}{" "}
+ {b.versionString}
+
+
+ {b.projectCode}
+ {b.episode ? ` · ep ${b.episode}` : ""}
+
+
+
+
+ {statusLabel(b.status)}
+
+
+
+ {formatDistanceToNowStrict(b.since)}
+
+ {/* The reason can be an aerender error several lines long;
+ it is clamped here and given in full on hover. */}
+
+ {b.reason}
+
+
+ {b.artistName ?? "—"}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/components/dashboard/FarmStrip.tsx b/components/dashboard/FarmStrip.tsx
new file mode 100644
index 0000000..511c02c
--- /dev/null
+++ b/components/dashboard/FarmStrip.tsx
@@ -0,0 +1,111 @@
+import Link from "next/link";
+import { formatDistanceToNowStrict } from "date-fns";
+import { cn } from "@/lib/utils";
+import { formatEta } from "@/app/(dashboard)/pipeline/status-colors";
+import type { FarmStatus } from "@/lib/dashboard/ops";
+
+/**
+ * Farm health: who is up, what is waiting, and who is running an old worker.
+ *
+ * The version row is not trivia — a worker's version is what decides whether
+ * it has a given fix, and a machine that quietly stayed behind keeps producing
+ * the bug everyone else has stopped seeing.
+ */
+export function FarmStrip({ farm }: { farm: FarmStatus }) {
+ return (
+
+
+
+ {farm.online} of {farm.total} online
+
+
+ Machines →
+
+
+
+ {farm.machines.length === 0 ? (
+
+ No machines have registered yet.
+
+ ) : (
+ farm.machines.map((m) => (
+
+
+ {m.name}
+
+ {m.currentJob
+ ? `${m.currentJob.shotCode ?? "job"} ${m.currentJob.versionString ?? ""} · ${Math.round(
+ (m.currentJob.progress ?? 0) * 100
+ )}% · ETA ${formatEta(m.currentJob.etaSeconds)}`
+ : m.status === "ONLINE"
+ ? "Idle"
+ : m.lastSeenAt
+ ? `Offline · last seen ${formatDistanceToNowStrict(new Date(m.lastSeenAt))} ago`
+ : "Never seen"}
+
+
+ {m.workerVersion ?? "—"}
+
+
+ ))
+ )}
+
+
+
+
+ {farm.queued.render}
+
+ renders queued
+
+
+
+ {farm.queued.preview}
+
+ previews queued
+
+
+
+ {farm.oldestQueuedMs == null
+ ? "—"
+ : formatDistanceToNowStrict(new Date(Date.now() - farm.oldestQueuedMs))}
+
+ oldest wait
+
+
+
+ {farm.behindMachines.length > 0 && (
+
+ ▲
+
+ {farm.behindMachines.length} machine
+ {farm.behindMachines.length === 1 ? " is" : "s are"} behind{" "}
+ {farm.latestWorkerVersion} —{" "}
+ {farm.behindMachines
+ .map((m) => `${m.name} (${m.workerVersion ?? "unknown"})`)
+ .join(", ")}
+
+
+ )}
+
+ );
+}
diff --git a/components/dashboard/StatsCards.tsx b/components/dashboard/StatsCards.tsx
index 4be4823..6b8a1f4 100644
--- a/components/dashboard/StatsCards.tsx
+++ b/components/dashboard/StatsCards.tsx
@@ -1,12 +1,11 @@
-"use client";
-
+import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
- Clock,
- AlertCircle,
- CheckCircle2,
- XCircle,
+ AlertTriangle,
+ ClipboardCheck,
+ PackageCheck,
+ Eye,
TrendingUp,
} from "lucide-react";
import type { DashboardStats } from "@/types";
@@ -15,47 +14,54 @@ interface StatsCardsProps {
stats: DashboardStats;
}
+/**
+ * Five numbers that can go down. Each one links to the page that lists what it
+ * counts — a figure with no way through to the rows behind it is wallpaper.
+ */
export function StatsCards({ stats }: StatsCardsProps) {
const cards = [
{
- label: "Awaiting Review",
- value: stats.awaitingReview,
- icon: Clock,
- color: "text-amber-400",
- bg: "bg-amber-500/10",
- ring: "ring-amber-500/20",
- },
- {
- label: "Needs Revisions",
- value: stats.needsRevisions,
- icon: AlertCircle,
- color: "text-orange-400",
- bg: "bg-orange-500/10",
- ring: "ring-orange-500/20",
- },
- {
- label: "Approved",
- value: stats.approved,
- icon: CheckCircle2,
- color: "text-emerald-400",
- bg: "bg-emerald-500/10",
- ring: "ring-emerald-500/20",
- },
- {
- label: "Overdue Tasks",
- value: stats.tasksOverdue ?? stats.overdue,
- icon: XCircle,
+ label: "Blocked",
+ value: stats.blocked,
+ href: "#blocked",
+ icon: AlertTriangle,
color: "text-red-400",
bg: "bg-red-500/10",
- ring: "ring-red-500/20",
+ // The only card that earns emphasis when it is non-zero: everything else
+ // here is a queue depth, this one is work that has stopped.
+ emphasise: stats.blocked > 0,
},
{
- label: "Active Projects",
- value: stats.activeProjects,
- icon: TrendingUp,
+ label: "In QC",
+ value: stats.inQc,
+ href: "/pipeline?status=READY_FOR_QC",
+ icon: ClipboardCheck,
+ color: "text-amber-400",
+ bg: "bg-amber-500/10",
+ },
+ {
+ label: "Ready to deliver",
+ value: stats.readyToDeliver,
+ href: "/pipeline/deliveries",
+ icon: PackageCheck,
+ color: "text-green-400",
+ bg: "bg-green-500/10",
+ },
+ {
+ label: "With the client",
+ value: stats.awaitingClientReview,
+ href: "/playlist",
+ icon: Eye,
color: "text-blue-400",
bg: "bg-blue-500/10",
- ring: "ring-blue-500/20",
+ },
+ {
+ label: "Active projects",
+ value: stats.activeProjects,
+ href: "/projects",
+ icon: TrendingUp,
+ color: "text-zinc-400",
+ bg: "bg-zinc-500/10",
},
];
@@ -64,19 +70,31 @@ export function StatsCards({ stats }: StatsCardsProps) {
{cards.map((card) => {
const Icon = card.icon;
return (
-
-
-
-
-
- {card.value}
-
-
{card.label}
-
-
+
+ {card.value}
+
+
{card.label}
+
+
+
);
})}
diff --git a/lib/dashboard/ops.test.ts b/lib/dashboard/ops.test.ts
new file mode 100644
index 0000000..5411fce
--- /dev/null
+++ b/lib/dashboard/ops.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from "vitest";
+import { compareVersions } from "./ops";
+
+/**
+ * The farm strip flags machines running an older worker than the newest one
+ * out there, which is only as reliable as this comparison: a plain string
+ * compare ranks "1.10.0" below "1.9.0" and would quietly stop reporting drift
+ * on the day the minor version reaches double digits.
+ */
+describe("compareVersions", () => {
+ it("orders by numeric component, not lexically", () => {
+ expect(compareVersions("1.10.0", "1.9.0")).toBeGreaterThan(0);
+ expect(compareVersions("1.9.0", "1.10.0")).toBeLessThan(0);
+ });
+
+ it("ignores the build stamp publish.ps1 appends", () => {
+ // The exe reports "1.6.1+fe420b9"; two machines on the same release built
+ // from different commits are not drifting.
+ expect(compareVersions("1.6.1+fe420b9", "1.6.1")).toBe(0);
+ expect(compareVersions("1.6.1+fe420b9", "1.6.0+2a88880")).toBeGreaterThan(0);
+ });
+
+ it("treats a missing component as zero", () => {
+ expect(compareVersions("1.6", "1.6.0")).toBe(0);
+ expect(compareVersions("1.6", "1.6.1")).toBeLessThan(0);
+ });
+
+ it("is transitive enough to pick a maximum", () => {
+ const versions = ["1.5.2", "1.6.1", "1.6.0", "1.10.0"];
+ const latest = versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
+ expect(latest).toBe("1.10.0");
+ });
+});
diff --git a/lib/dashboard/ops.ts b/lib/dashboard/ops.ts
new file mode 100644
index 0000000..47c3937
--- /dev/null
+++ b/lib/dashboard/ops.ts
@@ -0,0 +1,316 @@
+import { db } from "@/lib/db";
+import { getConfigNumber } from "@/lib/render-pipeline/config";
+import { listMachines } from "@/lib/render-pipeline/machines";
+import type { ExportStatus, Prisma } from "@prisma/client";
+
+/**
+ * Data for the operations dashboard.
+ *
+ * Everything here is derived from state the pipeline maintains on its own —
+ * export transitions, validation results, QC reviews, worker heartbeats — and
+ * never from a field a human has to remember to fill in. The dashboard this
+ * replaced counted `Task.dueDate`, which nothing in the pipeline writes, so
+ * its headline numbers were structurally always zero.
+ */
+
+/** Terminal-until-someone-acts: these never move on their own. */
+const FAILED_STATUSES: ExportStatus[] = [
+ "RENDER_FAILED",
+ "VALIDATION_FAILED",
+ "PREVIEW_FAILED",
+ "QC_FAILED",
+];
+
+/** In flight: fine until they stop progressing, then they are stuck. */
+const IN_FLIGHT_STATUSES: ExportStatus[] = [
+ "QUEUED",
+ "RENDERING",
+ "VALIDATING",
+ "GENERATING_PREVIEW",
+];
+
+export type BlockedSeverity = "critical" | "warning";
+
+export interface BlockedExport {
+ exportId: string;
+ shotId: string;
+ shotCode: string;
+ versionString: string;
+ projectCode: string;
+ episode: string | null;
+ status: ExportStatus;
+ severity: BlockedSeverity;
+ /** When it entered the status it is stuck in. */
+ since: Date;
+ /** What a human has to do something about, in one line. */
+ reason: string;
+ artistName: string | null;
+}
+
+/**
+ * Everything that has stopped moving and needs a person.
+ *
+ * Two kinds: exports in a failed state (which stay there forever — nothing
+ * retries a VALIDATION_FAILED on its own), and exports sitting in a live state
+ * far longer than that state should take, which is how a dead worker or a
+ * forgotten QC pass shows up. A failed export is currently only visible if
+ * someone thinks to open the render queue and filter for it.
+ */
+export async function getBlockedExports(limit = 25): Promise {
+ const [qcStaleHours, stalledHours] = await Promise.all([
+ getConfigNumber("dashboard.qcStaleHours"),
+ getConfigNumber("dashboard.stalledJobHours"),
+ ]);
+ const now = Date.now();
+ const qcCutoff = new Date(now - qcStaleHours * 3600_000);
+ const stalledCutoff = new Date(now - stalledHours * 3600_000);
+
+ const rows = await db.export.findMany({
+ where: {
+ // Finished shows would otherwise fill the list with failures nobody
+ // intends to fix. Export carries a projectId but no project relation, so
+ // the scope is reached through the shot.
+ shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
+ OR: [
+ { status: { in: FAILED_STATUSES } },
+ { status: "READY_FOR_QC", statusChangedAt: { lt: qcCutoff } },
+ { status: { in: IN_FLIGHT_STATUSES }, statusChangedAt: { lt: stalledCutoff } },
+ ],
+ },
+ orderBy: { statusChangedAt: "asc" },
+ take: limit,
+ select: {
+ id: true,
+ shotId: true,
+ versionString: true,
+ status: true,
+ statusChangedAt: true,
+ shot: {
+ select: {
+ shotCode: true,
+ episode: true,
+ artist: { select: { name: true, email: true } },
+ project: { select: { code: true } },
+ },
+ },
+ // Only the failures matter for the reason line, and only a couple of
+ // them: the point is to say what to look at, not to reproduce the
+ // validation report.
+ validations: {
+ where: { status: "FAIL" },
+ orderBy: { createdAt: "desc" },
+ take: 3,
+ select: { checkName: true, expected: true, actual: true, message: true },
+ },
+ qcReviews: {
+ orderBy: { createdAt: "desc" },
+ take: 1,
+ select: { result: true, notes: true, reviewerName: true },
+ },
+ renderJobs: {
+ orderBy: { createdAt: "desc" },
+ take: 1,
+ select: {
+ errorMessage: true,
+ status: true,
+ attempt: true,
+ maxAttempts: true,
+ machine: { select: { name: true } },
+ },
+ },
+ },
+ });
+
+ const blocked = rows.map((row): BlockedExport => {
+ const job = row.renderJobs[0];
+ const qc = row.qcReviews[0];
+ let reason: string;
+
+ switch (row.status) {
+ case "VALIDATION_FAILED": {
+ const first = row.validations[0];
+ const extra = row.validations.length > 1 ? ` (+${row.validations.length - 1} more)` : "";
+ reason = first
+ ? `${first.checkName} — ${first.message ?? `expected ${first.expected ?? "?"}, got ${first.actual ?? "?"}`}${extra}`
+ : "Validation failed";
+ break;
+ }
+ case "QC_FAILED":
+ reason = qc?.notes?.trim()
+ ? `${qc.notes.trim()}${qc.reviewerName ? ` — ${qc.reviewerName}` : ""}`
+ : "Failed technical QC";
+ break;
+ case "RENDER_FAILED":
+ case "PREVIEW_FAILED":
+ reason =
+ job?.errorMessage?.trim() ||
+ (row.status === "RENDER_FAILED" ? "Render failed" : "Preview build failed");
+ if (job && job.attempt >= job.maxAttempts) reason += ` (attempt ${job.attempt}/${job.maxAttempts})`;
+ break;
+ case "READY_FOR_QC":
+ reason = "Waiting on a QC pass";
+ break;
+ default:
+ // Still nominally in flight, but past the point where that is normal.
+ reason = job?.machine?.name
+ ? `No progress since claim on ${job.machine.name}`
+ : "Queued with no machine picking it up";
+ }
+
+ return {
+ exportId: row.id,
+ shotId: row.shotId,
+ shotCode: row.shot.shotCode,
+ versionString: row.versionString,
+ projectCode: row.shot.project.code,
+ episode: row.shot.episode,
+ status: row.status,
+ severity: FAILED_STATUSES.includes(row.status) ? "critical" : "warning",
+ since: row.statusChangedAt,
+ reason,
+ artistName: row.shot.artist?.name ?? row.shot.artist?.email ?? null,
+ };
+ });
+
+ // Failures first, then whatever has been waiting longest. Both orderings
+ // matter: severity decides what to fix, age decides what to fix first.
+ return blocked.sort((a, b) => {
+ if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1;
+ return a.since.getTime() - b.since.getTime();
+ });
+}
+
+export interface OpsCounts {
+ blocked: number;
+ inQc: number;
+ readyToDeliver: number;
+ awaitingClientReview: number;
+ activeProjects: number;
+}
+
+/**
+ * The headline numbers, all scoped to live productions.
+ *
+ * The counters these replaced were unscoped and cumulative — "approved" across
+ * every project the studio has ever finished only ever goes up, so it never
+ * told anyone anything. Each of these can go down, which is what makes it
+ * worth looking at twice in a day.
+ */
+export async function getOpsCounts(): Promise {
+ // Export has a projectId column but no project relation, so live-production
+ // scope is reached through the shot.
+ const liveProject: Prisma.ExportWhereInput = {
+ shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
+ };
+ const [qcStaleHours, stalledHours] = await Promise.all([
+ getConfigNumber("dashboard.qcStaleHours"),
+ getConfigNumber("dashboard.stalledJobHours"),
+ ]);
+ const now = Date.now();
+
+ const [blocked, inQc, readyToDeliver, awaitingClientReview, activeProjects] = await Promise.all([
+ db.export.count({
+ where: {
+ ...liveProject,
+ OR: [
+ { status: { in: FAILED_STATUSES } },
+ { status: "READY_FOR_QC", statusChangedAt: { lt: new Date(now - qcStaleHours * 3600_000) } },
+ {
+ status: { in: IN_FLIGHT_STATUSES },
+ statusChangedAt: { lt: new Date(now - stalledHours * 3600_000) },
+ },
+ ],
+ },
+ }),
+ db.export.count({ where: { ...liveProject, status: "READY_FOR_QC" } }),
+ db.export.count({ where: { ...liveProject, status: "READY_FOR_DELIVERY" } }),
+ // Sent to the client and still unanswered — the wait that is not ours to
+ // end, as opposed to notes we have not acted on yet.
+ db.version.count({
+ where: {
+ approvalStatus: "PENDING_REVIEW",
+ isClientVisible: true,
+ isLatest: true,
+ shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
+ },
+ }),
+ db.project.count({ where: { status: "ACTIVE" } }),
+ ]);
+
+ return { blocked, inQc, readyToDeliver, awaitingClientReview, activeProjects };
+}
+
+export interface FarmStatus {
+ machines: Awaited>;
+ online: number;
+ total: number;
+ queued: { render: number; preview: number; delivery: number; blender: number; total: number };
+ running: number;
+ /** Age of the oldest job still waiting to be claimed, in ms. */
+ oldestQueuedMs: number | null;
+ /** Highest worker version any machine reports, and who is behind it. */
+ latestWorkerVersion: string | null;
+ behindMachines: { name: string; workerVersion: string | null }[];
+}
+
+/**
+ * Farm health in one strip: who is up, what is waiting, and — because a
+ * worker's version is what decides whether it has a given fix — which
+ * machines are running something older than the newest one out there.
+ */
+export async function getFarmStatus(): Promise {
+ const [machines, queuedByType, running, oldest] = await Promise.all([
+ listMachines(),
+ db.renderJob.groupBy({ by: ["type"], where: { status: "QUEUED" }, _count: { _all: true } }),
+ db.renderJob.count({ where: { status: { in: ["CLAIMED", "RUNNING"] } } }),
+ db.renderJob.findFirst({
+ where: { status: "QUEUED" },
+ orderBy: { createdAt: "asc" },
+ select: { createdAt: true },
+ }),
+ ]);
+
+ const countOf = (type: string) =>
+ queuedByType.find((q) => q.type === type)?._count._all ?? 0;
+
+ const versions = machines
+ .map((m) => m.workerVersion)
+ .filter((v): v is string => Boolean(v));
+ const latest = versions.length ? versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b)) : null;
+
+ return {
+ machines,
+ online: machines.filter((m) => m.status === "ONLINE").length,
+ total: machines.length,
+ queued: {
+ render: countOf("AE_RENDER"),
+ preview: countOf("PREVIEW_ONLY"),
+ delivery: countOf("DELIVERY_BUILD"),
+ blender: countOf("BLENDER_RENDER"),
+ total: queuedByType.reduce((sum, q) => sum + q._count._all, 0),
+ },
+ running,
+ oldestQueuedMs: oldest ? Date.now() - oldest.createdAt.getTime() : null,
+ latestWorkerVersion: latest,
+ behindMachines: latest
+ ? machines
+ .filter((m) => m.workerVersion !== latest)
+ .map((m) => ({ name: m.name, workerVersion: m.workerVersion }))
+ : [],
+ };
+}
+
+/**
+ * Compares "1.6.1+fe420b9" style versions numerically, ignoring the build
+ * stamp publish.ps1 appends. A string compare would rank 1.10.0 below 1.9.0.
+ */
+export function compareVersions(a: string, b: string): number {
+ const parts = (v: string) =>
+ v.split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
+ const [pa, pb] = [parts(a), parts(b)];
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
+ if (diff !== 0) return diff;
+ }
+ return 0;
+}
diff --git a/lib/render-pipeline/config.ts b/lib/render-pipeline/config.ts
index 5cbadf8..79544f5 100644
--- a/lib/render-pipeline/config.ts
+++ b/lib/render-pipeline/config.ts
@@ -48,6 +48,13 @@ export const PIPELINE_CONFIG_DEFAULTS = {
// names as a warning, so it is a config fix rather than a code change.
"preview.slateScopeProp": "Scope",
"preview.slateSubmissionProp": "Notes",
+ /**
+ * When the dashboard starts calling something stuck. An export waiting on QC
+ * is normal for a day and suspicious after two; an export in a live render
+ * state has either progressed or died long before four hours are up.
+ */
+ "dashboard.qcStaleHours": 48,
+ "dashboard.stalledJobHours": 4,
} as const;
export type PipelineConfigKey = keyof typeof PIPELINE_CONFIG_DEFAULTS;
diff --git a/types/index.ts b/types/index.ts
index 99c6e7e..5ece385 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -276,17 +276,17 @@ export interface AssetWithDetails {
// ── Dashboard Types ──────────────────────────────────────────────────────────
+/**
+ * Dashboard headline numbers. Every one is derived from pipeline state and
+ * scoped to live productions — see lib/dashboard/ops.ts. The previous shape
+ * carried task due/overdue counts, which read a column nothing writes.
+ */
export interface DashboardStats {
- awaitingReview: number;
- needsRevisions: number;
- approved: number;
- overdue: number;
+ blocked: number;
+ inQc: number;
+ readyToDeliver: number;
+ awaitingClientReview: number;
activeProjects: number;
- // Task widgets
- tasksDueToday: number;
- tasksInReview: number;
- tasksOverdue: number;
- myTasksCount: number;
}
// ── Notification Badge ────────────────────────────────────────────────────────