feat(render+node-agent+admin): install fonts on all render nodes + verify
Build backend images / build content-svc (push) Failing after 53s
Build backend images / build file-svc (push) Failing after 47s
Build backend images / build gateway (push) Failing after 52s
Build backend images / build identity-svc (push) Failing after 58s
Build backend images / build notification-svc (push) Failing after 55s
Build backend images / build render-svc (push) Failing after 59s
Build backend images / build studio-svc (push) Failing after 48s
Build backend images / build content-svc (push) Failing after 53s
Build backend images / build file-svc (push) Failing after 47s
Build backend images / build gateway (push) Failing after 52s
Build backend images / build identity-svc (push) Failing after 58s
Build backend images / build notification-svc (push) Failing after 55s
Build backend images / build render-svc (push) Failing after 59s
Build backend images / build studio-svc (push) Failing after 48s
Push a font once → every node installs it → admin sees per-node status. - render-svc: font_requests + node_fonts tables (mig 25); admin GET/POST/DELETE /v1/node-fonts (with per-node status matrix); internal (HMAC) GET pending + POST status for node-agents - node-agent: fontSyncLoop polls pending fonts every 60s, downloads, installs (Windows Fonts dir + registry / macOS / linux fc-cache), reports Installed/Failed - gateway: /v1/node-fonts/* → render - admin /admin/node-fonts: upload a .ttf/.otf → install on all nodes; per-node Installed/Pending/Failed badges + counts + delete Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ export default async function AdminLayout({
|
||||
{ href: "/admin/discounts", label: t("discounts") },
|
||||
{ href: "/admin/settings", label: t("siteSettings") },
|
||||
{ href: "/admin/nodes", label: t("nodes") },
|
||||
{ href: "/admin/node-fonts", label: t("nodeFonts") },
|
||||
{ href: "/admin/renders", label: t("renderQueue") },
|
||||
];
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { NodeFontsAdmin } from "@/components/admin/NodeFontsAdmin";
|
||||
|
||||
export default function Page() {
|
||||
return <NodeFontsAdmin />;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||
|
||||
interface NodeStatus { node_id: string; node_name: string; status: string; error?: string | null }
|
||||
interface FontReq {
|
||||
id: string; name: string; system_name?: string | null; file_url: string;
|
||||
installed_count: number; total_nodes: number; nodes: NodeStatus[];
|
||||
}
|
||||
|
||||
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm 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";
|
||||
|
||||
const statusBadge = (s: string) => {
|
||||
const map: Record<string, string> = {
|
||||
Installed: "bg-emerald-500/15 text-emerald-300",
|
||||
Pending: "bg-amber-500/15 text-amber-300",
|
||||
Failed: "bg-red-500/15 text-red-300",
|
||||
};
|
||||
const fa: Record<string, string> = { Installed: "نصبشده", Pending: "در انتظار", Failed: "ناموفق" };
|
||||
return <span className={`rounded px-1.5 py-0.5 text-[10px] ${map[s] ?? map.Pending}`}>{fa[s] ?? s}</span>;
|
||||
};
|
||||
|
||||
export function NodeFontsAdmin() {
|
||||
const [rows, setRows] = useState<FontReq[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState("");
|
||||
const [systemName, setSystemName] = useState("");
|
||||
const [fileUrl, setFileUrl] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const r = await fetch("/api/admin/resource/node-fonts", { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||||
setRows(r?.items ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const add = async () => {
|
||||
setSaving(true); setMsg(null);
|
||||
const res = await fetch("/api/admin/resource/node-fonts", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, system_name: systemName, file_url: fileUrl }),
|
||||
});
|
||||
const d = await res.json().catch(() => null);
|
||||
setMsg(res.ok ? "فونت برای نصب روی همهٔ نودها ثبت شد ✓" : (d?.message ?? "خطا"));
|
||||
setSaving(false);
|
||||
if (res.ok) { setName(""); setSystemName(""); setFileUrl(""); reload(); }
|
||||
};
|
||||
|
||||
const remove = async (f: FontReq) => {
|
||||
if (!confirm(`فونت «${f.name}» از فهرست نودها حذف شود؟`)) return;
|
||||
await fetch(`/api/admin/resource/node-fonts/${f.id}`, { method: "DELETE" });
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5" dir="rtl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">فونت روی نودها</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">یک فونت را یکبار ثبت کنید تا روی همهٔ نودهای رندر نصب شود و وضعیت نصب هر نود را ببینید.</p>
|
||||
</div>
|
||||
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={reload}>بروزرسانی وضعیت</button>
|
||||
</div>
|
||||
|
||||
<section className={`${card} p-5`}>
|
||||
<h2 className="text-sm font-semibold text-white">افزودن فونت برای نصب روی نودها</h2>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={lbl}>نام نمایشی *</label><input className={inp} value={name} onChange={(e) => setName(e.target.value)} placeholder="Vazirmatn" /></div>
|
||||
<div><label className={lbl}>نام سیستمی فونت (که AE میشناسد)</label><input className={inp} dir="ltr" value={systemName} onChange={(e) => setSystemName(e.target.value)} placeholder="Vazirmatn-Regular" /></div>
|
||||
<div className="sm:col-span-2"><label className={lbl}>فایل فونت (.ttf / .otf) *</label><FileUploadField value={fileUrl} onChange={setFileUrl} accept=".ttf,.otf,.ttc" /></div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button className={btn} onClick={add} disabled={saving || !name || !fileUrl}>{saving ? "..." : "نصب روی همهٔ نودها"}</button>
|
||||
{msg && <span className="text-xs text-gray-400">{msg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">در حال بارگذاری…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className={`${card} p-6 text-center text-sm text-gray-500`}>هنوز فونتی برای نودها ثبت نشده.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((f) => (
|
||||
<div key={f.id} className={`${card} p-4`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="font-medium text-white">{f.name}</span>
|
||||
{f.system_name && <span className="ms-2 text-xs text-gray-500" dir="ltr">{f.system_name}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400">{f.installed_count.toLocaleString("fa-IR")} / {f.total_nodes.toLocaleString("fa-IR")} نود نصبشده</span>
|
||||
<a href={f.file_url} target="_blank" rel="noreferrer" className="text-xs text-indigo-400 hover:underline">فایل</a>
|
||||
<button className="rounded-lg border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10" onClick={() => remove(f)}>حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
{f.nodes.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-gray-600">هنوز نودی ثبت نشده است.</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{f.nodes.map((n) => (
|
||||
<span key={n.node_id} className="inline-flex items-center gap-1.5 rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2 py-1 text-xs text-gray-300" title={n.error ?? ""}>
|
||||
{n.node_name || n.node_id.slice(0, 8)} {statusBadge(n.status)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user