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" },
});
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PersianDateInput } from "@/components/admin/PersianDateInput";
const RENDER_SERVICE_KEY = "render_service";
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const lbl = "mb-1 block text-xs font-medium text-gray-400";
interface RenderService {
enabled: boolean;
messageFa: string;
messageEn: string;
untilDate: string; // ISO "YYYY-MM-DD" or ""
}
const DEFAULTS: RenderService = {
enabled: true,
messageFa: "سرویس رندر در حال حاضر فعال نیست. لطفاً بعداً دوباره تلاش کنید.",
messageEn: "The render service is currently unavailable. Please try again later.",
untilDate: "",
};
export function RenderEngineAdmin() {
const [state, setState] = useState<RenderService>(DEFAULTS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/resource/settings/all", { cache: "no-store" });
const data = await res.json();
const rows: Array<{ key: string; value: string }> = Array.isArray(data) ? data : data?.items ?? [];
const row = rows.find((r) => r.key === RENDER_SERVICE_KEY);
if (row?.value) {
try {
const v = typeof row.value === "string" ? JSON.parse(row.value) : row.value;
setState({
enabled: v?.enabled !== false,
messageFa: v?.messageFa ?? DEFAULTS.messageFa,
messageEn: v?.messageEn ?? DEFAULTS.messageEn,
untilDate: v?.untilDate ?? "",
});
} catch {
setState(DEFAULTS);
}
} else {
setState(DEFAULTS);
}
} catch {
setError("بارگذاری تنظیمات ناموفق بود");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
const save = async () => {
setSaving(true);
setError(null);
setMsg(null);
try {
const res = await fetch("/api/admin/resource/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: RENDER_SERVICE_KEY,
value: JSON.stringify(state),
description: "وضعیت موتور رندر (فعال/غیرفعال + پیام)",
is_secret: false,
}),
});
if (!res.ok) {
const d = await res.json().catch(() => null);
throw new Error(d?.error ?? "ذخیره‌سازی ناموفق بود");
}
setMsg("ذخیره شد.");
} catch (e) {
setError(e instanceof Error ? e.message : "ذخیره‌سازی ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-white">موتور رندر</h1>
<p className="mt-1 text-sm text-gray-400">
وقتی هیچ نود رندری در دسترس نیست، رندر را غیرفعال کنید. کاربران نمیتوانند رندر جدید شروع کنند و پیام شما به آنها نمایش داده میشود.
</p>
</div>
<button className={btn} onClick={save} disabled={saving || loading}>
{saving ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
{msg && <p className="rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">{msg}</p>}
{loading ? (
<p className="py-10 text-center text-gray-500">در حال بارگذاری</p>
) : (
<div className={`${card} space-y-4 p-5`}>
<label className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={state.enabled}
onClick={() => setState((s) => ({ ...s, enabled: !s.enabled }))}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${state.enabled ? "bg-emerald-600" : "bg-gray-600"}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${state.enabled ? "left-0.5" : "left-[22px]"}`}
/>
</button>
<span className="text-sm text-gray-200">
{state.enabled ? "موتور رندر فعال است" : "موتور رندر غیرفعال است (رندر مسدود می‌شود)"}
</span>
</label>
<div className={state.enabled ? "pointer-events-none opacity-50" : ""}>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={lbl}>پیام (فارسی)</label>
<textarea
className={`${inp} min-h-[80px]`}
dir="rtl"
value={state.messageFa}
onChange={(e) => setState((s) => ({ ...s, messageFa: e.target.value }))}
/>
</div>
<div>
<label className={lbl}>پیام (English)</label>
<textarea
className={`${inp} min-h-[80px]`}
dir="ltr"
value={state.messageEn}
onChange={(e) => setState((s) => ({ ...s, messageEn: e.target.value }))}
/>
</div>
<div>
<label className={lbl}>تا تاریخ (اختیاری)</label>
<PersianDateInput
value={state.untilDate}
onChange={(iso) => setState((s) => ({ ...s, untilDate: iso }))}
/>
<p className="mt-1 text-[11px] text-gray-500">در صورت تعیین، تاریخ به پیام افزوده میشود.</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Render-engine kill switch. Admins can disable new renders (e.g. when no render
* node is available) via the `render_service` Website Setting; the studio then
* blocks "start render" and shows a localized "unavailable until <date>" message.
*
* Stored as one jsonb setting — no backend/migration. Reads fail OPEN (renders
* stay enabled) so a settings hiccup never blocks a working render farm.
*/
import { gatewayUrl } from "@/lib/api/gateway";
export const RENDER_SERVICE_KEY = "render_service";
export interface RenderServiceStatus {
enabled: boolean;
messageFa?: string;
messageEn?: string;
untilDate?: string; // ISO date (optional)
}
export async function fetchRenderServiceStatus(): Promise<RenderServiceStatus> {
try {
const res = await fetch(gatewayUrl("/v1/settings/"), {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!res.ok) return { enabled: true };
const rows = (await res.json()) as Array<{ key: string; value: string }>;
const row = Array.isArray(rows) ? rows.find((r) => r.key === RENDER_SERVICE_KEY) : null;
if (!row?.value) return { enabled: true };
const v = typeof row.value === "string" ? JSON.parse(row.value) : row.value;
return {
enabled: v?.enabled !== false,
messageFa: v?.messageFa ?? undefined,
messageEn: v?.messageEn ?? undefined,
untilDate: v?.untilDate ?? undefined,
};
} catch {
return { enabled: true }; // fail open
}
}
/** Pick the localized message, appending the "until" date when present. */
export function renderServiceMessage(
status: RenderServiceStatus,
locale: string,
fallback: string,
): string {
const base = (locale === "fa" ? status.messageFa : status.messageEn) || status.messageEn || status.messageFa || fallback;
if (status.untilDate) {
try {
const d = new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(status.untilDate));
return `${base} (${d})`;
} catch {
return base;
}
}
return base;
}