feat(admin): render-engine kill switch (block renders + show message)

Lets an admin disable rendering when no render node is available — users can't
start new renders and see a localized "service unavailable until <date>" message.

- Admin → فارم رندر → موتور رندر (RenderEngineAdmin): on/off toggle + fa/en message
  + optional Jalali "until" date; saved as one `render_service` Website Setting
  (jsonb) via /v1/settings — no backend change, no migration.
- lib/render-service.ts: fetchRenderServiceStatus (fail-open) + renderServiceMessage
  (locale + appends the date).
- Enforcement: POST /api/render returns 503 {code:render_disabled, messages} when off;
  studio render page reads GET /api/render/service on mount → disables "شروع رندر"
  and shows the banner, and handles the 503 on click.
- i18n: appAdminLayout.renderEngine (fa+en, parity 1045/1045). tsc + next build clean.
  Verified: disabled setting → /api/render/service returns enabled:false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-12 09:47:42 +03:30
parent a1414f06f6
commit 61ba526122
9 changed files with 325 additions and 4 deletions
+1
View File
@@ -64,6 +64,7 @@ export default async function AdminLayout({
{
title: "فارم رندر",
items: [
{ href: "/admin/render-engine", label: t("renderEngine") },
{ href: "/admin/nodes", label: t("nodes") },
{ href: "/admin/node-fonts", label: t("nodeFonts") },
{ href: "/admin/renders", label: t("renderQueue") },
@@ -0,0 +1,7 @@
"use client";
import { RenderEngineAdmin } from "@/components/admin/RenderEngineAdmin";
export default function Page() {
return <RenderEngineAdmin />;
}
@@ -12,9 +12,12 @@ import {
RefreshCw,
} from "lucide-react";
import { useLocale } from "next-intl";
import { apiFetch } from "@/lib/api/fetch";
import { RENDER_EXPORT_PRESETS, type RenderExportPreset } from "@/lib/render-presets";
import type { RenderSettings } from "@/lib/render-schemas";
import { renderServiceMessage, type RenderServiceStatus } from "@/lib/render-service";
import { cn } from "@/lib/utils";
type Phase = "config" | "submitting" | "polling" | "completed" | "failed";
@@ -57,6 +60,13 @@ export default function RenderPage() {
const projectId = params.projectId;
const presetKey = search.get("preset") as RenderExportPreset | null;
const locale = useLocale();
const [renderService, setRenderService] = useState<RenderServiceStatus | null>(null);
const serviceDisabled = renderService?.enabled === false;
const serviceMessage = renderService
? renderServiceMessage(renderService, locale, "سرویس رندر در حال حاضر در دسترس نیست.")
: "";
const [resolution, setResolution] = useState<RenderSettings["resolution"]>("1080p");
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
const [phase, setPhase] = useState<Phase>("config");
@@ -80,6 +90,23 @@ export default function RenderPage() {
setFps(cfg.settings.fps);
}, [presetKey]);
// Render-engine kill switch: learn whether new renders are allowed.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/render/service", { cache: "no-store" });
const data = (await res.json()) as RenderServiceStatus;
if (!cancelled) setRenderService(data);
} catch {
if (!cancelled) setRenderService({ enabled: true });
}
})();
return () => {
cancelled = true;
};
}, []);
// On mount: resume this project's render if one is in flight, or flag a render
// running on another project so we can block starting a new one.
useEffect(() => {
@@ -170,7 +197,26 @@ export default function RenderPage() {
settings: { resolution, format: "mp4" as const, fps },
}),
});
const data = (await res.json()) as { jobId?: string; error?: string; code?: string };
const data = (await res.json()) as {
jobId?: string;
error?: string;
code?: string;
messageFa?: string;
messageEn?: string;
untilDate?: string;
};
if (data.code === "render_disabled") {
const status: RenderServiceStatus = {
enabled: false,
messageFa: data.messageFa,
messageEn: data.messageEn,
untilDate: data.untilDate,
};
setRenderService(status);
setPhase("config");
setErrorMessage(renderServiceMessage(status, locale, data.error ?? "سرویس رندر در حال حاضر در دسترس نیست."));
return;
}
if (res.status === 409 || data.code === "active_render_limit") {
setPhase("config");
setErrorMessage(
@@ -191,7 +237,7 @@ export default function RenderPage() {
setPhase("failed");
setErrorMessage("Could not reach the render service.");
}
}, [projectId, resolution, fps]);
}, [projectId, resolution, fps, locale]);
const backToStudio = `/studio/video/${projectId}`;
const isBusy = phase === "submitting" || phase === "polling";
@@ -313,7 +359,12 @@ export default function RenderPage() {
) : (
// Config
<div className="w-full max-w-md space-y-5">
{errorMessage && (
{serviceDisabled && (
<div className="rounded-lg border border-amber-900/50 bg-amber-950/40 px-4 py-3 text-sm text-amber-200">
{serviceMessage}
</div>
)}
{errorMessage && !serviceDisabled && (
<p className="rounded-lg border border-amber-900/50 bg-amber-950/40 px-3 py-2 text-sm text-amber-300">
{errorMessage}
</p>
@@ -372,7 +423,7 @@ export default function RenderPage() {
<button
type="button"
onClick={startRender}
disabled={!!blockingJobId}
disabled={!!blockingJobId || serviceDisabled}
className="w-full rounded-lg bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
شروع رندر
+16
View File
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { getAccessToken } from "@/lib/auth/session";
import { createRenderJob } from "@/lib/render-jobs";
import { renderRequestSchema } from "@/lib/render-schemas";
import { fetchRenderServiceStatus } from "@/lib/render-service";
export const runtime = "nodejs";
@@ -12,6 +13,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Render-engine kill switch (admin-controlled). Block new renders when disabled.
const service = await fetchRenderServiceStatus();
if (!service.enabled) {
return NextResponse.json(
{
error: service.messageEn || service.messageFa || "Render service is currently unavailable.",
code: "render_disabled",
messageFa: service.messageFa,
messageEn: service.messageEn,
untilDate: service.untilDate,
},
{ status: 503 },
);
}
let body: unknown;
try {
body = await request.json();
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { fetchRenderServiceStatus } from "@/lib/render-service";
export const runtime = "nodejs";
/** Public read of the render-engine kill switch, so the studio can disable the
* "start render" button and show the unavailable message before the user clicks. */
export async function GET() {
const status = await fetchRenderServiceStatus();
return NextResponse.json(status, {
headers: { "Cache-Control": "no-store" },
});
}