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" },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user