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:
@@ -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);
|
||||||
+2
-1
@@ -334,7 +334,8 @@
|
|||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"routes": "Internal Routes",
|
"routes": "Internal Routes",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"projects": "Projects"
|
"projects": "Projects",
|
||||||
|
"nodeFonts": "Node Fonts"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "Render Nodes",
|
"title": "Render Nodes",
|
||||||
|
|||||||
+2
-1
@@ -334,7 +334,8 @@
|
|||||||
"comments": "نظرات",
|
"comments": "نظرات",
|
||||||
"routes": "مسیرهای داخلی",
|
"routes": "مسیرهای داخلی",
|
||||||
"integrations": "یکپارچهسازیها",
|
"integrations": "یکپارچهسازیها",
|
||||||
"projects": "پروژهها"
|
"projects": "پروژهها",
|
||||||
|
"nodeFonts": "فونت نودها"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "نودهای رندر",
|
"title": "نودهای رندر",
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ func main() {
|
|||||||
v1.Any("/snapshots/*path", heavyRL, auth, render.Handler())
|
v1.Any("/snapshots/*path", heavyRL, auth, render.Handler())
|
||||||
v1.Any("/exports/*path", apiRL, auth, render.Handler())
|
v1.Any("/exports/*path", apiRL, auth, render.Handler())
|
||||||
v1.Any("/nodes/*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())
|
v1.Any("/node-updates/*path", apiRL, auth, render.Handler())
|
||||||
|
|
||||||
// ── Notification Service ──────────────────────────────────────────────────
|
// ── Notification Service ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -121,9 +121,10 @@ func main() {
|
|||||||
|
|
||||||
// Main loops
|
// Main loops
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(2)
|
wg.Add(3)
|
||||||
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
|
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
|
||||||
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
|
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
|
||||||
|
go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
log.Printf("shutdown complete")
|
log.Printf("shutdown complete")
|
||||||
}
|
}
|
||||||
@@ -144,6 +145,44 @@ func (a *Agent) registerOnline(ctx context.Context) error {
|
|||||||
return nil
|
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 ────────────────────────────────────────────────────────────
|
// ── Heartbeat loop ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (a *Agent) heartbeatLoop(ctx context.Context) {
|
func (a *Agent) heartbeatLoop(ctx context.Context) {
|
||||||
|
|||||||
@@ -61,6 +61,48 @@ func decodeJSON(resp *http.Response, out any) error {
|
|||||||
return json.NewDecoder(resp.Body).Decode(out)
|
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 ──────────────────────────────────────────────────────────────
|
// ── Domain types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// OnlineRequest is sent once on startup to mark the node Ready.
|
// OnlineRequest is sent once on startup to mark the node Ready.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ func main() {
|
|||||||
snapH := handlers.NewSnapshotHandler(store)
|
snapH := handlers.NewSnapshotHandler(store)
|
||||||
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
||||||
nodeH := handlers.NewNodeHandler(store)
|
nodeH := handlers.NewNodeHandler(store)
|
||||||
|
fontH := handlers.NewFontHandler(store)
|
||||||
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
||||||
|
|
||||||
// ── Router ────────────────────────────────────────────────────────────────
|
// ── Router ────────────────────────────────────────────────────────────────
|
||||||
@@ -136,12 +137,22 @@ func main() {
|
|||||||
v1.GET("/node-updates", auth, admin, nodeH.ListUpdates)
|
v1.GET("/node-updates", auth, admin, nodeH.ListUpdates)
|
||||||
v1.POST("/node-updates/:update_id/rollout", auth, admin, nodeH.Rollout)
|
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 (node agents only — HMAC auth) ───────────────────────────────
|
||||||
internal := v1.Group("/internal", nodeAuth)
|
internal := v1.Group("/internal", nodeAuth)
|
||||||
{
|
{
|
||||||
internal.POST("/nodes/:node_id/heartbeat", internalH.Heartbeat)
|
internal.POST("/nodes/:node_id/heartbeat", internalH.Heartbeat)
|
||||||
internal.POST("/nodes/:node_id/online", internalH.Online)
|
internal.POST("/nodes/:node_id/online", internalH.Online)
|
||||||
internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate)
|
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/claim", internalH.Claim)
|
||||||
internal.POST("/render/jobs/:job_id/preview", internalH.Preview)
|
internal.POST("/render/jobs/:job_id/preview", internalH.Preview)
|
||||||
internal.POST("/render/jobs/:job_id/output-upload-url", internalH.OutputUploadURL)
|
internal.POST("/render/jobs/:job_id/output-upload-url", internalH.OutputUploadURL)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export default async function AdminLayout({
|
|||||||
{ href: "/admin/discounts", label: t("discounts") },
|
{ href: "/admin/discounts", label: t("discounts") },
|
||||||
{ href: "/admin/settings", label: t("siteSettings") },
|
{ href: "/admin/settings", label: t("siteSettings") },
|
||||||
{ href: "/admin/nodes", label: t("nodes") },
|
{ href: "/admin/nodes", label: t("nodes") },
|
||||||
|
{ href: "/admin/node-fonts", label: t("nodeFonts") },
|
||||||
{ href: "/admin/renders", label: t("renderQueue") },
|
{ href: "/admin/renders", label: t("renderQueue") },
|
||||||
];
|
];
|
||||||
return (
|
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