feat(render): always-available, fully-cancel render controls

The backend cancel was solid (CancelJob/StopJob; the dev worker abandons a cancelled
job, a real node kills its process) — but the UI couldn't reach it: the render page
had NO cancel button, and the global progress pill's X only HID the pill (the job kept
running). So a render couldn't actually be stopped from where you watch it.

- Render page: a prominent «لغو رندر» button while a render is in flight (Queued or
  Running); cancelRender() calls /renders/:id/cancel and returns to config optimistically.
  The poll now also handles a `cancelled` status (when stopped from another surface).
- Global pill: the X now CANCELS the render (with confirm) instead of just hiding it —
  so any in-flight render is cancellable from any page.
- (Dashboard MyRenders already had a working cancel.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 11:31:56 +03:30
parent 6814e64593
commit 40fdcf280f
2 changed files with 54 additions and 8 deletions
@@ -10,6 +10,7 @@ import {
Link2, Link2,
Loader2, Loader2,
RefreshCw, RefreshCw,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
@@ -205,6 +206,12 @@ export default function RenderPage() {
} else if (data.status === "failed") { } else if (data.status === "failed") {
setPhase("failed"); setPhase("failed");
setErrorMessage(data.errorMessage ?? "Render failed."); setErrorMessage(data.errorMessage ?? "Render failed.");
} else if (data.status?.toLowerCase() === "cancelled") {
// Cancelled here or from another surface (pill / dashboard) — reflect it.
setPhase("config");
setProgress(0);
setEtaSec(null);
setErrorMessage("رندر لغو شد.");
} }
} catch { } catch {
setPhase("failed"); setPhase("failed");
@@ -279,6 +286,26 @@ export default function RenderPage() {
} }
}, [projectId, resolution, fps, locale]); }, [projectId, resolution, fps, locale]);
/** Fully cancel the active render — works in any state (Queued or Running). The
* server marks it Cancelled (the dev worker abandons it, a real node kills its
* process), so it stops fully. Optimistically return to config right away. */
const cancelRender = useCallback(async () => {
if (!jobId) return;
const id = jobId;
setPhase("config");
setProgress(0);
setEtaSec(null);
setProgressMessage("");
setPreviewB64(null);
setErrorMessage("رندر لغو شد.");
setJobId(null);
try {
await apiFetch(`/api/renders/${id}/cancel`, { method: "POST" });
} catch {
/* server is the source of truth; the dashboard can also cancel */
}
}, [jobId]);
const backToStudio = `/studio/video/${projectId}`; const backToStudio = `/studio/video/${projectId}`;
const isBusy = phase === "submitting" || phase === "polling"; const isBusy = phase === "submitting" || phase === "polling";
@@ -393,9 +420,19 @@ export default function RenderPage() {
</button> </button>
</div> </div>
) : isBusy ? ( ) : isBusy ? (
<p className="text-sm text-gray-400"> <div className="flex w-full max-w-md flex-col items-center gap-3">
<p className="text-center text-sm text-gray-400">
میتوانید این صفحه را ببندید؛ رندر در پسزمینه ادامه مییابد و از هر صفحهای قابل پیگیری است. میتوانید این صفحه را ببندید؛ رندر در پسزمینه ادامه مییابد و از هر صفحهای قابل پیگیری است.
</p> </p>
<button
type="button"
onClick={cancelRender}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-red-900/60 bg-red-950/40 px-5 py-2.5 text-sm font-medium text-red-300 transition-colors hover:bg-red-900/40"
>
<XCircle className="h-4 w-4" />
لغو رندر
</button>
</div>
) : ( ) : (
// Config // Config
<div className="w-full max-w-md space-y-5"> <div className="w-full max-w-md space-y-5">
+14 -5
View File
@@ -103,16 +103,25 @@ export function GlobalRenderProgress({ authed }: { authed: boolean }) {
</div> </div>
</div> </div>
{/* Dismiss (hides until job id changes) */} {/* Cancel — fully stops the render server-side (works from any page). */}
<span <span
role="button" role="button"
tabIndex={-1} tabIndex={-1}
onClick={(e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
setDismissed(active.id); if (!window.confirm("این رندر لغو شود؟")) return;
const id = active.id;
setActive(null);
setDismissed(id);
try {
await fetch(`/api/renders/${id}/cancel`, { method: "POST" });
} catch {
/* server is the source of truth; ignore transient errors */
}
}} }}
className="shrink-0 rounded p-1 text-gray-600 hover:text-gray-300" className="shrink-0 rounded p-1 text-gray-500 transition-colors hover:bg-red-500/20 hover:text-red-300"
aria-label="پنهان کردن" aria-label="لغو رندر"
title="لغو رندر"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</span> </span>