Checks fail on renders that are genuinely fine — a plate the engine cannot reach, an expectation that does not apply to this shot — and a VALIDATION_FAILED export was a dead end, blocking delivery on a shot a human can see is correct. Override validation on the Export detail page resumes the pipeline where a pass would have handed off: preview, then READY_FOR_QC. It never skips QC and never delivers directly, so the frames are still looked at — the override waives the automated check, not the human one. The failed ValidationResult rows are left untouched rather than rewritten, so the QC queue keeps listing them as warnings and whoever QCs the shot sees what was waved through. The reason is required and recorded with the checks it covered on the export's timeline, and the validation panel keeps an "overridden" badge long after the event scrolls away. Restricted to admin/producer/supervisor: waving a failed check through is a supervisor's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, RotateCcw, XCircle } from "lucide-react";
|
||||
import { ArrowLeft, RotateCcw, XCircle, ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -105,6 +106,9 @@ const CHECK_STYLES: Record<string, string> = {
|
||||
export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [overrideOpen, setOverrideOpen] = useState(false);
|
||||
const [overrideReason, setOverrideReason] = useState("");
|
||||
const [overriding, setOverriding] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["pipeline-export", exportId],
|
||||
@@ -125,6 +129,31 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
||||
}
|
||||
|
||||
async function overrideValidation() {
|
||||
setOverriding(true);
|
||||
try {
|
||||
const res = await fetch(`/api/render/exports/${exportId}/override-validation`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: overrideReason }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: "Override failed", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Validation overridden",
|
||||
description: `${(body.overridden ?? []).join(", ") || "No failed checks recorded"} — continuing to preview and QC`,
|
||||
});
|
||||
setOverrideOpen(false);
|
||||
setOverrideReason("");
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
||||
} finally {
|
||||
setOverriding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
|
||||
if (error || !data) {
|
||||
return (
|
||||
@@ -178,6 +207,11 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
|
||||
</Button>
|
||||
)}
|
||||
{e.status === "VALIDATION_FAILED" && !overrideOpen && (
|
||||
<Button variant="outline" size="sm" onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldAlert className="h-4 w-4 mr-2 text-amber-400" /> Override validation
|
||||
</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
|
||||
@@ -186,6 +220,48 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Override — a human overruling the checks, so the reason is required
|
||||
and kept on the export's timeline. */}
|
||||
{overrideOpen && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-3">
|
||||
<div className="text-sm text-amber-200">
|
||||
Override the failed validation for {e.shot.shotCode} {e.versionString}?
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
The render continues to preview and then QC — it is not delivered directly, and the failed
|
||||
checks stay on record and still show up in the QC queue. Say why, so the next person
|
||||
reading this export knows what was waved through.
|
||||
</p>
|
||||
<textarea
|
||||
value={overrideReason}
|
||||
onChange={(ev) => setOverrideReason(ev.target.value)}
|
||||
rows={2}
|
||||
autoFocus
|
||||
placeholder="e.g. plate unreachable from the render node; timecode checked by hand against the EDL"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={overrideValidation}
|
||||
disabled={overrideReason.trim().length < 3 || overriding}
|
||||
>
|
||||
{overriding ? "Overriding…" : "Override and continue"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOverrideOpen(false);
|
||||
setOverrideReason("");
|
||||
}}
|
||||
>
|
||||
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>
|
||||
@@ -227,6 +303,13 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
<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 flex items-center gap-3">
|
||||
Validation
|
||||
{/* A waved-through failure must stay visible long after the event
|
||||
scrolls down the timeline. */}
|
||||
{e.events.some((ev) => ev.note?.startsWith("Validation overridden")) && (
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-400">
|
||||
overridden
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const latestRun = e.validations[e.validations.length - 1].renderJobId;
|
||||
const rows = e.validations.filter((v) => v.renderJobId === latestRun);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { overrideValidation, overrideValidationSchema } from "@/lib/render-pipeline/validation";
|
||||
|
||||
// ── POST /api/render/exports/{exportId}/override-validation ──────────────────
|
||||
//
|
||||
// A human overruling a failed validation run. Resumes the pipeline at the
|
||||
// preview stage, so the export still goes through QC; the failed checks stay on
|
||||
// record and keep showing up as warnings in the QC queue.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ exportId: string }> }
|
||||
) {
|
||||
try {
|
||||
// Waving a failed check through is a supervisor's call, not any user's.
|
||||
const user = await requirePipelineAdmin();
|
||||
const { exportId } = await params;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = overrideValidationSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "A reason is required", details: parsed.error.issues }, { status: 422 });
|
||||
}
|
||||
|
||||
const result = await overrideValidation(exportId, {
|
||||
reason: parsed.data.reason,
|
||||
userId: user.id,
|
||||
userName: user.name ?? user.email ?? null,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user