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

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:
soroush.asadi
2026-06-03 06:33:48 +03:30
parent ca0c05db10
commit 7f2f65dd8a
14 changed files with 648 additions and 3 deletions
+1
View File
@@ -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 />;
}
+122
View File
@@ -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>
);
}