diff --git a/messages/en.json b/messages/en.json index a2469fd..e3d70f0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -369,6 +369,7 @@ "auto": { "appAdminLayout": { "brand": "FlatRender", + "renderEngine": "Render Engine", "nodes": "Nodes", "renderQueue": "Render Queue", "backToDashboard": "← Back to Dashboard", diff --git a/messages/fa.json b/messages/fa.json index 2bf1533..54c12f1 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -369,6 +369,7 @@ "auto": { "appAdminLayout": { "brand": "فلترندر", + "renderEngine": "موتور رندر", "nodes": "نودها", "renderQueue": "صف رندر", "backToDashboard": "← بازگشت به داشبورد", diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 07e3bfb..2f971e8 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -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") }, diff --git a/src/app/[locale]/admin/render-engine/page.tsx b/src/app/[locale]/admin/render-engine/page.tsx new file mode 100644 index 0000000..7d4a943 --- /dev/null +++ b/src/app/[locale]/admin/render-engine/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { RenderEngineAdmin } from "@/components/admin/RenderEngineAdmin"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/studio/render/[projectId]/page.tsx b/src/app/[locale]/studio/render/[projectId]/page.tsx index cebc005..00f3201 100644 --- a/src/app/[locale]/studio/render/[projectId]/page.tsx +++ b/src/app/[locale]/studio/render/[projectId]/page.tsx @@ -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(null); + const serviceDisabled = renderService?.enabled === false; + const serviceMessage = renderService + ? renderServiceMessage(renderService, locale, "سرویس رندر در حال حاضر در دسترس نیست.") + : ""; + const [resolution, setResolution] = useState("1080p"); const [fps, setFps] = useState(30); const [phase, setPhase] = useState("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 - {errorMessage && ( + {serviceDisabled && ( + + {serviceMessage} + + )} + {errorMessage && !serviceDisabled && ( {errorMessage} @@ -372,7 +423,7 @@ export default function RenderPage() { شروع رندر diff --git a/src/app/api/render/route.ts b/src/app/api/render/route.ts index 21be826..6ea6190 100644 --- a/src/app/api/render/route.ts +++ b/src/app/api/render/route.ts @@ -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(); diff --git a/src/app/api/render/service/route.ts b/src/app/api/render/service/route.ts new file mode 100644 index 0000000..fd7ff56 --- /dev/null +++ b/src/app/api/render/service/route.ts @@ -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" }, + }); +} diff --git a/src/components/admin/RenderEngineAdmin.tsx b/src/components/admin/RenderEngineAdmin.tsx new file mode 100644 index 0000000..9360476 --- /dev/null +++ b/src/components/admin/RenderEngineAdmin.tsx @@ -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(DEFAULTS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(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 ( + + + + موتور رندر + + وقتی هیچ نود رندری در دسترس نیست، رندر را غیرفعال کنید. کاربران نمیتوانند رندر جدید شروع کنند و پیام شما به آنها نمایش داده میشود. + + + + {saving ? "در حال ذخیره…" : "ذخیره"} + + + + {error && {error}} + {msg && {msg}} + + {loading ? ( + در حال بارگذاری… + ) : ( + + + 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"}`} + > + + + + {state.enabled ? "موتور رندر فعال است" : "موتور رندر غیرفعال است (رندر مسدود میشود)"} + + + + + + + پیام (فارسی) + setState((s) => ({ ...s, messageFa: e.target.value }))} + /> + + + پیام (English) + setState((s) => ({ ...s, messageEn: e.target.value }))} + /> + + + تا تاریخ (اختیاری) + setState((s) => ({ ...s, untilDate: iso }))} + /> + در صورت تعیین، تاریخ به پیام افزوده میشود. + + + + + )} + + ); +} diff --git a/src/lib/render-service.ts b/src/lib/render-service.ts new file mode 100644 index 0000000..723d280 --- /dev/null +++ b/src/lib/render-service.ts @@ -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 " 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 { + 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; +}
{errorMessage}
+ وقتی هیچ نود رندری در دسترس نیست، رندر را غیرفعال کنید. کاربران نمیتوانند رندر جدید شروع کنند و پیام شما به آنها نمایش داده میشود. +
{error}
{msg}
در حال بارگذاری…
در صورت تعیین، تاریخ به پیام افزوده میشود.