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:
@@ -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"
|
||||
>
|
||||
شروع رندر
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user