From 7f2f65dd8ac9d91b82b9cb7e4df99046f0f87bde Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 06:33:48 +0330 Subject: [PATCH] feat(render+node-agent+admin): install fonts on all render nodes + verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../db/migrations/25_render_node_fonts.sql | 28 ++++ messages/en.json | 3 +- messages/fa.json | 3 +- services/gateway/cmd/server/main.go | 1 + services/node-agent/cmd/agent/main.go | 41 ++++- services/node-agent/internal/client/client.go | 42 +++++ services/node-agent/internal/runner/fonts.go | 92 +++++++++++ services/render/cmd/server/main.go | 11 ++ services/render/internal/db/fonts.go | 144 ++++++++++++++++++ services/render/internal/handlers/fonts.go | 107 +++++++++++++ services/render/internal/models/fonts.go | 49 ++++++ src/app/[locale]/admin/layout.tsx | 1 + src/app/[locale]/admin/node-fonts/page.tsx | 7 + src/components/admin/NodeFontsAdmin.tsx | 122 +++++++++++++++ 14 files changed, 648 insertions(+), 3 deletions(-) create mode 100644 backend/db/migrations/25_render_node_fonts.sql create mode 100644 services/node-agent/internal/runner/fonts.go create mode 100644 services/render/internal/db/fonts.go create mode 100644 services/render/internal/handlers/fonts.go create mode 100644 services/render/internal/models/fonts.go create mode 100644 src/app/[locale]/admin/node-fonts/page.tsx create mode 100644 src/components/admin/NodeFontsAdmin.tsx diff --git a/backend/db/migrations/25_render_node_fonts.sql b/backend/db/migrations/25_render_node_fonts.sql new file mode 100644 index 0000000..6f68b24 --- /dev/null +++ b/backend/db/migrations/25_render_node_fonts.sql @@ -0,0 +1,28 @@ +-- ===================================================================== +-- RENDER SCHEMA — Part 25: font distribution to render nodes +-- Admin requests a font to be installed on all nodes; each node-agent installs +-- it and reports status, so the admin can verify per-node installation. +-- ===================================================================== + +SET search_path TO render, public; + +-- A font that should be present on every render node. +CREATE TABLE IF NOT EXISTS font_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + system_name TEXT, -- the OS/PostScript family name AE looks up + file_url TEXT NOT NULL, -- .ttf/.otf URL the node downloads + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Per-node install status for a requested font. +CREATE TABLE IF NOT EXISTS node_fonts ( + node_id UUID NOT NULL, + font_request_id UUID NOT NULL REFERENCES font_requests(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'Pending', -- Pending | Installed | Failed + error TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (node_id, font_request_id) +); + +CREATE INDEX IF NOT EXISTS idx_node_fonts_request ON node_fonts (font_request_id); diff --git a/messages/en.json b/messages/en.json index e6ad1e6..c50ab05 100644 --- a/messages/en.json +++ b/messages/en.json @@ -334,7 +334,8 @@ "comments": "Comments", "routes": "Internal Routes", "integrations": "Integrations", - "projects": "Projects" + "projects": "Projects", + "nodeFonts": "Node Fonts" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index f3231f2..f56a3e4 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -334,7 +334,8 @@ "comments": "نظرات", "routes": "مسیرهای داخلی", "integrations": "یکپارچه‌سازی‌ها", - "projects": "پروژه‌ها" + "projects": "پروژه‌ها", + "nodeFonts": "فونت نودها" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index 5950667..b1454ba 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -135,6 +135,7 @@ func main() { v1.Any("/snapshots/*path", heavyRL, auth, render.Handler()) v1.Any("/exports/*path", apiRL, auth, render.Handler()) v1.Any("/nodes/*path", apiRL, auth, render.Handler()) + v1.Any("/node-fonts/*path", apiRL, auth, render.Handler()) v1.Any("/node-updates/*path", apiRL, auth, render.Handler()) // ── Notification Service ────────────────────────────────────────────────── diff --git a/services/node-agent/cmd/agent/main.go b/services/node-agent/cmd/agent/main.go index 09e722b..c197faf 100644 --- a/services/node-agent/cmd/agent/main.go +++ b/services/node-agent/cmd/agent/main.go @@ -121,9 +121,10 @@ func main() { // Main loops var wg sync.WaitGroup - wg.Add(2) + wg.Add(3) go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }() go func() { defer wg.Done(); agent.pollLoop(ctx) }() + go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }() wg.Wait() log.Printf("shutdown complete") } @@ -144,6 +145,44 @@ func (a *Agent) registerOnline(ctx context.Context) error { return nil } +// ── Font sync loop ──────────────────────────────────────────────────────────── +// Periodically installs any fonts the orchestrator wants on this node and reports +// per-font status, so the admin can verify installation. + +func (a *Agent) fontSyncLoop(ctx context.Context) { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + a.syncFonts(ctx) // run once on startup + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.syncFonts(ctx) + } + } +} + +func (a *Agent) syncFonts(ctx context.Context) { + fonts, err := a.orch.PendingFonts(ctx, a.cfg.NodeID) + if err != nil { + return // transient; try again next tick + } + for _, f := range fonts { + name := f.SystemName + if name == "" { + name = f.Name + } + if err := runner.InstallFont(ctx, f.FileURL, name); err != nil { + log.Printf("font install failed (%s): %v", f.Name, err) + _ = a.orch.ReportFont(ctx, a.cfg.NodeID, f.ID, "Failed", err.Error()) + continue + } + log.Printf("font installed: %s", f.Name) + _ = a.orch.ReportFont(ctx, a.cfg.NodeID, f.ID, "Installed", "") + } +} + // ── Heartbeat loop ──────────────────────────────────────────────────────────── func (a *Agent) heartbeatLoop(ctx context.Context) { diff --git a/services/node-agent/internal/client/client.go b/services/node-agent/internal/client/client.go index c74417e..d3f2d36 100644 --- a/services/node-agent/internal/client/client.go +++ b/services/node-agent/internal/client/client.go @@ -61,6 +61,48 @@ func decodeJSON(resp *http.Response, out any) error { return json.NewDecoder(resp.Body).Decode(out) } +// ── Fonts ─────────────────────────────────────────────────────────────────── + +type PendingFont struct { + ID string `json:"id"` + Name string `json:"name"` + SystemName string `json:"system_name"` + FileURL string `json:"file_url"` +} + +type pendingFontsResp struct { + Fonts []PendingFont `json:"fonts"` +} + +// PendingFonts returns fonts this node still needs to install. +func (c *Client) PendingFonts(ctx context.Context, nodeID string) ([]PendingFont, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/v1/internal/nodes/%s/fonts/pending", nodeID), nil) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("pending fonts status %d", resp.StatusCode) + } + var out pendingFontsResp + if err := decodeJSON(resp, &out); err != nil { + return nil, err + } + return out.Fonts, nil +} + +// ReportFont reports a font install result (Installed | Failed). +func (c *Client) ReportFont(ctx context.Context, nodeID, requestID, status, errMsg string) error { + resp, err := c.do(ctx, http.MethodPost, + fmt.Sprintf("/v1/internal/nodes/%s/fonts/%s/status", nodeID, requestID), + map[string]string{"status": status, "error": errMsg}) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + // ── Domain types ────────────────────────────────────────────────────────────── // OnlineRequest is sent once on startup to mark the node Ready. diff --git a/services/node-agent/internal/runner/fonts.go b/services/node-agent/internal/runner/fonts.go new file mode 100644 index 0000000..e2f5239 --- /dev/null +++ b/services/node-agent/internal/runner/fonts.go @@ -0,0 +1,92 @@ +package runner + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// InstallFont downloads a font file and installs it into the OS so After Effects +// can use it. Returns nil on success. Best-effort per platform. +func InstallFont(ctx context.Context, fileURL, name string) error { + ext := filepath.Ext(strings.Split(fileURL, "?")[0]) + if ext == "" { + ext = ".ttf" + } + fileName := sanitizeFontName(name) + ext + + tmpDir, err := os.MkdirTemp("", "fr-font-") + if err != nil { + return fmt.Errorf("tmp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + tmp := filepath.Join(tmpDir, fileName) + if _, err := DownloadFile(ctx, fileURL, tmp); err != nil { + return fmt.Errorf("download: %w", err) + } + + switch runtime.GOOS { + case "windows": + return installFontWindows(ctx, tmp, fileName) + case "darwin": + return copyFile(tmp, filepath.Join(os.Getenv("HOME"), "Library", "Fonts", fileName)) + default: // linux + dir := filepath.Join(os.Getenv("HOME"), ".local", "share", "fonts") + _ = os.MkdirAll(dir, 0o755) + if err := copyFile(tmp, filepath.Join(dir, fileName)); err != nil { + return err + } + _ = exec.CommandContext(ctx, "fc-cache", "-f").Run() + return nil + } +} + +func installFontWindows(ctx context.Context, src, fileName string) error { + // Prefer the machine-wide Fonts dir; fall back to the per-user dir. + dst := filepath.Join(os.Getenv("WINDIR"), "Fonts", fileName) + if err := copyFile(src, dst); err != nil { + userFonts := filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Windows", "Fonts") + _ = os.MkdirAll(userFonts, 0o755) + dst = filepath.Join(userFonts, fileName) + if err := copyFile(src, dst); err != nil { + return fmt.Errorf("copy font: %w", err) + } + } + // Register so Windows/AE pick it up without a reboot (best-effort; needs admin). + _ = exec.CommandContext(ctx, "reg", "add", + `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`, + "/v", fileName, "/t", "REG_SZ", "/d", dst, "/f").Run() + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} + +func sanitizeFontName(s string) string { + s = strings.TrimSpace(s) + repl := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", " ", "_") + s = repl.Replace(s) + if s == "" { + return "font" + } + return s +} diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index 1851f9d..1532d84 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -67,6 +67,7 @@ func main() { snapH := handlers.NewSnapshotHandler(store) exportH := handlers.NewExportHandler(store, mc, minioBucket) nodeH := handlers.NewNodeHandler(store) + fontH := handlers.NewFontHandler(store) internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket) // ── Router ──────────────────────────────────────────────────────────────── @@ -136,12 +137,22 @@ func main() { v1.GET("/node-updates", auth, admin, nodeH.ListUpdates) v1.POST("/node-updates/:update_id/rollout", auth, admin, nodeH.Rollout) + // ── Node fonts: install a font on all nodes + verify (admin) ────────────── + nodeFonts := v1.Group("/node-fonts", auth, admin) + { + nodeFonts.GET("", fontH.List) + nodeFonts.POST("", fontH.Create) + nodeFonts.DELETE("/:id", fontH.Delete) + } + // ── Internal (node agents only — HMAC auth) ─────────────────────────────── internal := v1.Group("/internal", nodeAuth) { internal.POST("/nodes/:node_id/heartbeat", internalH.Heartbeat) internal.POST("/nodes/:node_id/online", internalH.Online) internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate) + internal.GET("/nodes/:node_id/fonts/pending", fontH.Pending) + internal.POST("/nodes/:node_id/fonts/:request_id/status", fontH.Report) internal.POST("/render/jobs/claim", internalH.Claim) internal.POST("/render/jobs/:job_id/preview", internalH.Preview) internal.POST("/render/jobs/:job_id/output-upload-url", internalH.OutputUploadURL) diff --git a/services/render/internal/db/fonts.go b/services/render/internal/db/fonts.go new file mode 100644 index 0000000..ee2a2ff --- /dev/null +++ b/services/render/internal/db/fonts.go @@ -0,0 +1,144 @@ +package db + +import ( + "context" + + "github.com/flatrender/render-svc/internal/models" + "github.com/google/uuid" +) + +func (s *Store) CreateFontRequest(ctx context.Context, name, systemName, fileURL string) (*models.FontRequest, error) { + var fr models.FontRequest + var sys *string + if systemName != "" { + sys = &systemName + } + err := s.pool.QueryRow(ctx, + `INSERT INTO render.font_requests (name, system_name, file_url) + VALUES ($1, $2, $3) RETURNING id, name, system_name, file_url, created_at`, + name, sys, fileURL).Scan(&fr.ID, &fr.Name, &fr.SystemName, &fr.FileURL, &fr.CreatedAt) + return &fr, err +} + +func (s *Store) DeleteFontRequest(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM render.font_requests WHERE id = $1`, id) + return err +} + +// ListFontRequestsWithStatus returns every font request with a per-node status matrix +// (nodes without an explicit row are reported as Pending). +func (s *Store) ListFontRequestsWithStatus(ctx context.Context) ([]models.FontRequestWithStatus, error) { + // 1) active nodes + type node struct { + id uuid.UUID + name string + } + var nodes []node + rows, err := s.pool.Query(ctx, `SELECT id, name FROM render.render_nodes ORDER BY name`) + if err != nil { + return nil, err + } + for rows.Next() { + var n node + if err := rows.Scan(&n.id, &n.name); err != nil { + rows.Close() + return nil, err + } + nodes = append(nodes, n) + } + rows.Close() + + // 2) requests + var reqs []models.FontRequest + rrows, err := s.pool.Query(ctx, `SELECT id, name, system_name, file_url, created_at FROM render.font_requests ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + for rrows.Next() { + var fr models.FontRequest + if err := rrows.Scan(&fr.ID, &fr.Name, &fr.SystemName, &fr.FileURL, &fr.CreatedAt); err != nil { + rrows.Close() + return nil, err + } + reqs = append(reqs, fr) + } + rrows.Close() + + // 3) explicit per-node statuses + type key struct { + req uuid.UUID + node uuid.UUID + } + statuses := map[key]models.NodeFontStatus{} + srows, err := s.pool.Query(ctx, `SELECT font_request_id, node_id, status, error, updated_at FROM render.node_fonts`) + if err != nil { + return nil, err + } + for srows.Next() { + var reqID, nodeID uuid.UUID + var st models.NodeFontStatus + if err := srows.Scan(&reqID, &nodeID, &st.Status, &st.Error, &st.UpdatedAt); err != nil { + srows.Close() + return nil, err + } + st.NodeID = nodeID + statuses[key{reqID, nodeID}] = st + } + srows.Close() + + out := make([]models.FontRequestWithStatus, 0, len(reqs)) + for _, fr := range reqs { + item := models.FontRequestWithStatus{FontRequest: fr, TotalNodes: len(nodes)} + for _, n := range nodes { + st, ok := statuses[key{fr.ID, n.id}] + if !ok { + st = models.NodeFontStatus{NodeID: n.id, Status: "Pending"} + } + st.NodeName = n.name + if st.Status == "Installed" { + item.InstalledCount++ + } + item.Nodes = append(item.Nodes, st) + } + out = append(out, item) + } + return out, nil +} + +// PendingFontsForNode returns fonts this node has not yet Installed (Pending or Failed → retry). +func (s *Store) PendingFontsForNode(ctx context.Context, nodeID uuid.UUID) ([]models.PendingFont, error) { + rows, err := s.pool.Query(ctx, ` + SELECT fr.id, fr.name, fr.system_name, fr.file_url + FROM render.font_requests fr + WHERE NOT EXISTS ( + SELECT 1 FROM render.node_fonts nf + WHERE nf.font_request_id = fr.id AND nf.node_id = $1 AND nf.status = 'Installed' + )`, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.PendingFont + for rows.Next() { + var f models.PendingFont + if err := rows.Scan(&f.ID, &f.Name, &f.SystemName, &f.FileURL); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +func (s *Store) ReportFontStatus(ctx context.Context, nodeID, requestID uuid.UUID, status, errMsg string) error { + var e *string + if errMsg != "" { + e = &errMsg + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO render.node_fonts (node_id, font_request_id, status, error, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (node_id, font_request_id) + DO UPDATE SET status = EXCLUDED.status, error = EXCLUDED.error, updated_at = NOW()`, + nodeID, requestID, status, e) + return err +} diff --git a/services/render/internal/handlers/fonts.go b/services/render/internal/handlers/fonts.go new file mode 100644 index 0000000..65adf0c --- /dev/null +++ b/services/render/internal/handlers/fonts.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "net/http" + + "github.com/flatrender/render-svc/internal/db" + "github.com/flatrender/render-svc/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type FontHandler struct { + store *db.Store +} + +func NewFontHandler(store *db.Store) *FontHandler { + return &FontHandler{store: store} +} + +// ── Admin ──────────────────────────────────────────────────────────────────── + +// GET /v1/node-fonts +func (h *FontHandler) List(c *gin.Context) { + items, err := h.store.ListFontRequestsWithStatus(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + if items == nil { + items = []models.FontRequestWithStatus{} + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +// POST /v1/node-fonts +func (h *FontHandler) Create(c *gin.Context) { + var req models.CreateFontRequestBody + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()}) + return + } + fr, err := h.store.CreateFontRequest(c.Request.Context(), req.Name, req.SystemName, req.FileURL) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + c.JSON(http.StatusCreated, fr) +} + +// DELETE /v1/node-fonts/:id +func (h *FontHandler) Delete(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"}) + return + } + if err := h.store.DeleteFontRequest(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +// ── Internal (node agents, HMAC auth) ──────────────────────────────────────── + +// GET /v1/internal/nodes/:node_id/fonts/pending +func (h *FontHandler) Pending(c *gin.Context) { + nodeID, err := uuid.Parse(c.Param("node_id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid node_id"}) + return + } + fonts, err := h.store.PendingFontsForNode(c.Request.Context(), nodeID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + if fonts == nil { + fonts = []models.PendingFont{} + } + c.JSON(http.StatusOK, gin.H{"fonts": fonts}) +} + +// POST /v1/internal/nodes/:node_id/fonts/:request_id/status +func (h *FontHandler) Report(c *gin.Context) { + nodeID, err := uuid.Parse(c.Param("node_id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid node_id"}) + return + } + reqID, err := uuid.Parse(c.Param("request_id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid request_id"}) + return + } + var body models.FontStatusReport + _ = c.ShouldBindJSON(&body) + status := body.Status + if status != "Installed" { + status = "Failed" + } + if err := h.store.ReportFontStatus(c.Request.Context(), nodeID, reqID, status, body.Error); err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/services/render/internal/models/fonts.go b/services/render/internal/models/fonts.go new file mode 100644 index 0000000..715fa72 --- /dev/null +++ b/services/render/internal/models/fonts.go @@ -0,0 +1,49 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type FontRequest struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + SystemName *string `json:"system_name,omitempty"` + FileURL string `json:"file_url"` + CreatedAt time.Time `json:"created_at"` +} + +type NodeFontStatus struct { + NodeID uuid.UUID `json:"node_id"` + NodeName string `json:"node_name"` + Status string `json:"status"` // Pending | Installed | Failed + Error *string `json:"error,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type FontRequestWithStatus struct { + FontRequest + InstalledCount int `json:"installed_count"` + TotalNodes int `json:"total_nodes"` + Nodes []NodeFontStatus `json:"nodes"` +} + +type CreateFontRequestBody struct { + Name string `json:"name" binding:"required"` + SystemName string `json:"system_name"` + FileURL string `json:"file_url" binding:"required"` +} + +// Sent to node-agents. +type PendingFont struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + SystemName *string `json:"system_name,omitempty"` + FileURL string `json:"file_url"` +} + +type FontStatusReport struct { + Status string `json:"status"` // Installed | Failed + Error string `json:"error"` +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 42ed9bd..b07f67a 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -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 ( diff --git a/src/app/[locale]/admin/node-fonts/page.tsx b/src/app/[locale]/admin/node-fonts/page.tsx new file mode 100644 index 0000000..f750850 --- /dev/null +++ b/src/app/[locale]/admin/node-fonts/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { NodeFontsAdmin } from "@/components/admin/NodeFontsAdmin"; + +export default function Page() { + return ; +} diff --git a/src/components/admin/NodeFontsAdmin.tsx b/src/components/admin/NodeFontsAdmin.tsx new file mode 100644 index 0000000..c81d278 --- /dev/null +++ b/src/components/admin/NodeFontsAdmin.tsx @@ -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 = { + 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 = { Installed: "نصب‌شده", Pending: "در انتظار", Failed: "ناموفق" }; + return {fa[s] ?? s}; +}; + +export function NodeFontsAdmin() { + const [rows, setRows] = useState([]); + 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(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 ( +
+
+
+

فونت روی نودها

+

یک فونت را یک‌بار ثبت کنید تا روی همهٔ نودهای رندر نصب شود و وضعیت نصب هر نود را ببینید.

+
+ +
+ +
+

افزودن فونت برای نصب روی نودها

+
+
setName(e.target.value)} placeholder="Vazirmatn" />
+
setSystemName(e.target.value)} placeholder="Vazirmatn-Regular" />
+
+
+
+ + {msg && {msg}} +
+
+ + {loading ? ( +

در حال بارگذاری…

+ ) : rows.length === 0 ? ( +

هنوز فونتی برای نودها ثبت نشده.

+ ) : ( +
+ {rows.map((f) => ( +
+
+
+ {f.name} + {f.system_name && {f.system_name}} +
+
+ {f.installed_count.toLocaleString("fa-IR")} / {f.total_nodes.toLocaleString("fa-IR")} نود نصب‌شده + فایل + +
+
+ {f.nodes.length === 0 ? ( +

هنوز نودی ثبت نشده است.

+ ) : ( +
+ {f.nodes.map((n) => ( + + {n.node_name || n.node_id.slice(0, 8)} {statusBadge(n.status)} + + ))} +
+ )} +
+ ))} +
+ )} +
+ ); +}