Files
vfxreview/lib/dashboard/ops.ts
T
twotalesanimation f30acef101
Deploy / deploy (push) Successful in 5m17s
feat(deliveries): delivery requests and upload/share evidence
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00

826 lines
27 KiB
TypeScript

import { db } from "@/lib/db";
import { getConfigNumber } from "@/lib/render-pipeline/config";
import { listMachines } from "@/lib/render-pipeline/machines";
import { handoffStage, type HandoffStage } from "@/lib/render-pipeline/delivery-handoff";
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(
opts: { artistId?: string; limit?: number } = {}
): Promise<BlockedExport[]> {
const { artistId, limit = 25 } = opts;
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"] } },
// An artist's dashboard shows the failures that are theirs to fix.
...(artistId ? { artistId } : {}),
},
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 type CountdownVerdict = "complete" | "overdue" | "stalled" | "behind" | "tight" | "ahead";
export interface EpisodeCountdown {
projectId: string;
projectCode: string;
projectName: string;
/** Null on a standard (non-episodic) show, which has one date of its own. */
episode: string | null;
dueDate: Date;
/** Calendar days to the due date; negative once it has passed. */
daysLeft: number;
total: number;
delivered: number;
inQc: number;
inProgress: number;
notStarted: number;
/** Shots per day still needed to land the rest by the due date. */
requiredPerDay: number;
/** Shots per day actually delivered over the last week. */
actualPerDay: number;
verdict: CountdownVerdict;
/**
* How late this is: the days the current rate overruns by when `verdict` is
* "behind", or the days the date has already passed when it is "overdue".
*/
daysBehind: number | null;
}
const DAY_MS = 86_400_000;
/** Shots past QC but not yet in a delivered package. */
const POST_QC_STATUSES: ExportStatus[] = ["READY_FOR_QC", "READY_FOR_DELIVERY", "PACKAGED"];
/**
* Turns "23 shots left, 14 days, 8 delivered last week" into a verdict.
*
* Kept free of the database so the arithmetic that decides whether a producer
* panics can be tested directly. The trailing week is the rate we have
* evidence for; anything else is a hope. A run that only just lands on the due
* date is reported as tight rather than fine — finishing on the last available
* day is luck, not schedule.
*/
export function projectPace(
remaining: number,
daysLeft: number,
deliveredThisWeek: number
): Pick<EpisodeCountdown, "requiredPerDay" | "actualPerDay" | "verdict" | "daysBehind"> {
const actualPerDay = deliveredThisWeek / 7;
const requiredPerDay = remaining <= 0 ? 0 : remaining / Math.max(daysLeft, 1);
if (remaining <= 0) {
return { requiredPerDay, actualPerDay, verdict: "complete", daysBehind: null };
}
if (daysLeft < 0) {
return { requiredPerDay, actualPerDay, verdict: "overdue", daysBehind: -daysLeft };
}
if (actualPerDay === 0) {
return { requiredPerDay, actualPerDay, verdict: "stalled", daysBehind: null };
}
const projectedDays = remaining / actualPerDay;
if (projectedDays > daysLeft) {
return {
requiredPerDay,
actualPerDay,
verdict: "behind",
daysBehind: Math.ceil(projectedDays - daysLeft),
};
}
return {
requiredPerDay,
actualPerDay,
verdict: projectedDays > daysLeft * 0.9 ? "tight" : "ahead",
daysBehind: null,
};
}
/**
* Are we going to make the date, per episode?
*
* A shot counts as delivered when it appears in a package that has actually
* gone out — a DeliveryItem in a DELIVERED EXR_DELIVERY package — or when it
* was recorded as delivered by hand before the pipeline existed
* (Shot.manualDeliveredAt), since those shots have no Export to package. Shot.status
* is deliberately not the measure: it says where the work is, not whether the
* client has the frames, and a review package (MOV_REVIEW) ships nothing.
*
* The run rate compares what is left per remaining day against what the studio
* actually managed per day over the last week, which is the only honest way to
* turn "23 shots to go" into "you are not going to make it".
*/
export async function getDeliveryCountdown(limit = 6): Promise<EpisodeCountdown[]> {
const projects = await db.project.findMany({
where: { status: "ACTIVE" },
select: {
id: true,
code: true,
name: true,
dueDate: true,
projectType: true,
episodeDueDates: { select: { episode: true, dueDate: true } },
},
});
if (projects.length === 0) return [];
const projectIds = projects.map((p) => p.id);
const weekAgo = new Date(Date.now() - 7 * DAY_MS);
const [shots, postQc, deliveredItems] = await Promise.all([
db.shot.findMany({
where: { projectId: { in: projectIds } },
select: { id: true, projectId: true, episode: true, status: true, manualDeliveredAt: true },
}),
db.export.findMany({
where: { status: { in: POST_QC_STATUSES }, shot: { projectId: { in: projectIds } } },
select: { shotId: true },
}),
db.deliveryItem.findMany({
where: {
package: {
projectId: { in: projectIds },
kind: "EXR_DELIVERY",
status: "DELIVERED",
},
},
select: { shotId: true, package: { select: { deliveredAt: true } } },
}),
]);
const postQcShots = new Set(postQc.map((e) => e.shotId));
// A shot can be in several packages (a redelivery); the first one out is the
// one that counts towards the rate.
const deliveredAt = new Map<string, Date>();
for (const item of deliveredItems) {
const when = item.package.deliveredAt;
if (!when) continue;
const seen = deliveredAt.get(item.shotId);
if (!seen || when < seen) deliveredAt.set(item.shotId, when);
}
for (const shot of shots) {
const when = shot.manualDeliveredAt;
if (!when) continue;
const seen = deliveredAt.get(shot.id);
if (!seen || when < seen) deliveredAt.set(shot.id, when);
}
const now = Date.now();
const rows: EpisodeCountdown[] = [];
for (const project of projects) {
// Episodic shows carry a date per episode; a standard show has one date on
// the project itself, and is treated as a single unnamed target.
const targets: { episode: string | null; dueDate: Date }[] =
project.projectType === "EPISODIC"
? project.episodeDueDates.map((e) => ({ episode: e.episode, dueDate: e.dueDate }))
: project.dueDate
? [{ episode: null, dueDate: project.dueDate }]
: [];
for (const target of targets) {
const targetShots = shots.filter(
(s) =>
s.projectId === project.id &&
(target.episode === null || (s.episode ?? "") === target.episode)
);
if (targetShots.length === 0) continue;
let delivered = 0;
let inQc = 0;
let inProgress = 0;
let notStarted = 0;
let deliveredThisWeek = 0;
for (const shot of targetShots) {
const when = deliveredAt.get(shot.id);
if (when) {
delivered++;
if (when >= weekAgo) deliveredThisWeek++;
} else if (postQcShots.has(shot.id)) {
inQc++;
} else if (shot.status === "WAITING") {
notStarted++;
} else {
inProgress++;
}
}
const total = targetShots.length;
const daysLeft = Math.ceil((target.dueDate.getTime() - now) / DAY_MS);
const pace = projectPace(total - delivered, daysLeft, deliveredThisWeek);
// A finished episode whose date has passed is history, not a countdown.
if (pace.verdict === "complete" && daysLeft < 0) continue;
rows.push({
projectId: project.id,
projectCode: project.code,
projectName: project.name,
episode: target.episode,
dueDate: target.dueDate,
daysLeft,
total,
delivered,
inQc,
inProgress,
notStarted,
...pace,
});
}
}
return rows.sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime()).slice(0, limit);
}
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(artistId?: string): Promise<OpsCounts> {
// Export has a projectId column but no project relation, so live-production
// scope is reached through the shot. An artist gets their own figures, so the
// cards agree with the blocked list underneath them rather than quoting a
// studio-wide number beside a personal one.
const liveProject: Prisma.ExportWhereInput = {
shot: {
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
...(artistId ? { artistId } : {}),
},
};
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"] } },
...(artistId ? { artistId } : {}),
},
},
}),
db.project.count({ where: { status: "ACTIVE" } }),
]);
return { blocked, inQc, readyToDeliver, awaitingClientReview, activeProjects };
}
/** How long an export may sit in READY_FOR_QC before the UI calls it stale. */
export function getQcStaleHours(): Promise<number> {
return getConfigNumber("dashboard.qcStaleHours");
}
export interface QcQueueEntry {
exportId: string;
shotCode: string;
versionString: string;
projectCode: string;
episode: string | null;
since: Date;
frameStart: number;
frameEnd: number;
submittedBy: string | null;
}
/**
* What is waiting on a human to look at the frames.
*
* QC is the last gate before anything can be packaged, and it is the only
* stage in the pipeline that cannot drain itself — everything before it is
* worked by the farm. Depth here is the single best predictor of a delivery
* slipping, and until now it had no home outside the render queue's filter.
*/
export async function getQcQueue(limit = 8): Promise<QcQueueEntry[]> {
const rows = await db.export.findMany({
where: {
status: "READY_FOR_QC",
shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
},
orderBy: { statusChangedAt: "asc" },
take: limit,
select: {
id: true,
versionString: true,
statusChangedAt: true,
frameStart: true,
frameEnd: true,
submittedByName: true,
shot: {
select: { shotCode: true, episode: true, project: { select: { code: true } } },
},
},
});
return rows.map((row) => ({
exportId: row.id,
shotCode: row.shot.shotCode,
versionString: row.versionString,
projectCode: row.shot.project.code,
episode: row.shot.episode,
since: row.statusChangedAt,
frameStart: row.frameStart,
frameEnd: row.frameEnd,
// Free text from the panel's config: Export keeps submittedById for the
// resolved account but carries no relation to it.
submittedBy: row.submittedByName,
}));
}
export interface DeliverySummary {
id: string;
packageNumber: number;
name: string;
projectCode: string;
episode: string;
kind: string;
status: string;
itemCount: number;
errorMessage: string | null;
/** Most recent of delivered/built/created — what the timestamp column means. */
at: Date;
atLabel: "delivered" | "built" | "created";
/** BUILDING for longer than a build plausibly takes. */
stuck: boolean;
}
/**
* Recent packages, and whether any of them went wrong.
*
* A failed delivery build is otherwise silent: the export stays
* READY_FOR_DELIVERY, nothing is on the delivery volume, and the only trace is
* a status on a page nobody opens between deliveries.
*/
export async function getRecentDeliveries(limit = 5): Promise<DeliverySummary[]> {
const rows = await db.deliveryPackage.findMany({
where: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
packageNumber: true,
name: true,
episode: true,
kind: true,
status: true,
errorMessage: true,
createdAt: true,
builtAt: true,
deliveredAt: true,
project: { select: { code: true } },
_count: { select: { items: true } },
},
});
const buildStuckAfter = 2 * 3600_000;
return rows.map((row) => {
const at = row.deliveredAt ?? row.builtAt ?? row.createdAt;
return {
id: row.id,
packageNumber: row.packageNumber,
name: row.name,
projectCode: row.project.code,
episode: row.episode,
kind: row.kind,
status: row.status,
itemCount: row._count.items,
errorMessage: row.errorMessage,
at,
atLabel: row.deliveredAt ? "delivered" : row.builtAt ? "built" : "created",
// A build that never finished leaves the package sitting in BUILDING
// forever; nothing times it out.
stuck: row.status === "BUILDING" && Date.now() - row.createdAt.getTime() > buildStuckAfter,
};
});
}
export interface AwaitingDelivery {
id: string;
packageNumber: number;
name: string;
projectCode: string;
episode: string;
itemCount: number;
stage: HandoffStage;
/** When the folder was ready to go — the clock the client is waiting on. */
builtAt: Date;
severity: "normal" | "warning" | "critical";
}
/**
* Built packages the client does not have yet.
*
* A READY package is the easiest thing in the pipeline to believe is done: the
* folder exists and every file verified, but nothing has left the building.
* It stays listed until the upload location and the person it was shared with
* are both on record, and gets louder the longer that takes.
*/
export async function getAwaitingDelivery(): Promise<AwaitingDelivery[]> {
const rows = await db.deliveryPackage.findMany({
where: {
kind: "EXR_DELIVERY",
status: "READY",
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
},
orderBy: { builtAt: "asc" },
select: {
id: true,
packageNumber: true,
name: true,
kind: true,
status: true,
episode: true,
createdAt: true,
builtAt: true,
uploadedAt: true,
project: { select: { code: true } },
_count: { select: { items: true } },
},
});
const day = 24 * 3600_000;
return rows.flatMap((row) => {
const stage = handoffStage(row);
if (!stage) return [];
const builtAt = row.builtAt ?? row.createdAt;
const age = Date.now() - builtAt.getTime();
return [
{
id: row.id,
packageNumber: row.packageNumber,
name: row.name,
projectCode: row.project.code,
episode: row.episode,
itemCount: row._count.items,
stage,
builtAt,
severity: age >= 2 * day ? "critical" : age >= day ? "warning" : "normal",
},
];
});
}
export interface CourtSide {
count: number;
items: { id: string; shotCode: string; label: string; href: string }[];
}
export interface WhoseCourt {
client: CourtSide;
us: CourtSide;
}
/**
* Which waits are ours and which are the client's.
*
* The distinction is the useful part: a version sitting unanswered for eleven
* days is a phone call, while notes nobody has actioned are a scheduling
* problem. Lumping both into "needs revisions" hid which was which.
*/
export async function getWhoseCourt(perSide = 4): Promise<WhoseCourt> {
const liveShot: Prisma.ShotWhereInput = {
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
};
const [withClient, openNotes] = await Promise.all([
db.version.findMany({
where: {
approvalStatus: "PENDING_REVIEW",
isClientVisible: true,
isLatest: true,
shot: liveShot,
},
// Longest wait first — that is the one worth chasing.
orderBy: [{ sharedAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
take: perSide,
select: {
id: true,
versionNumber: true,
sharedAt: true,
createdAt: true,
shot: { select: { id: true, shotCode: true } },
},
}),
// Unresolved notes on the version currently in play. Grouped in memory
// because the interesting unit is the shot, not the comment.
db.comment.findMany({
where: {
isResolved: false,
version: { isLatest: true, shot: liveShot },
},
select: { id: true, version: { select: { shot: { select: { id: true, shotCode: true } } } } },
}),
]);
const byShot = new Map<string, { shotCode: string; count: number }>();
for (const note of openNotes) {
const shot = note.version?.shot;
if (!shot) continue;
const entry = byShot.get(shot.id) ?? { shotCode: shot.shotCode, count: 0 };
entry.count++;
byShot.set(shot.id, entry);
}
const ours = [...byShot.entries()].sort((a, b) => b[1].count - a[1].count);
const days = (from: Date) => Math.max(0, Math.floor((Date.now() - from.getTime()) / DAY_MS));
return {
client: {
count: withClient.length,
items: withClient.map((v) => ({
id: v.id,
shotCode: v.shot?.shotCode ?? "—",
label: `${days(v.sharedAt ?? v.createdAt)}d`,
href: `/review/${v.id}`,
})),
},
us: {
count: ours.length,
items: ours.slice(0, perSide).map(([shotId, entry]) => ({
id: shotId,
shotCode: entry.shotCode,
label: `${entry.count} note${entry.count === 1 ? "" : "s"}`,
href: `/shot-status`,
})),
},
};
}
export interface FarmStatus {
machines: Awaited<ReturnType<typeof listMachines>>;
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<FarmStatus> {
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;
}