feat(nodes): live CPU/RAM/disk monitoring in the node list
Build backend images / build content-svc (push) Failing after 45s
Build backend images / build file-svc (push) Failing after 55s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 54s
Build backend images / build notification-svc (push) Failing after 53s
Build backend images / build render-svc (push) Failing after 47s
Build backend images / build studio-svc (push) Failing after 51s

- node-agent: internal/metrics — read CPU% (GetSystemTimes), RAM (GlobalMemoryStatusEx),
  disk used%/total (GetDiskFreeSpaceEx) via stdlib kernel32 (no external dep; windows
  build + non-windows stub). Heartbeat now reports cpu_pct/ram_available_mb/disk_used_pct/
  disk_total_gb + ae_running.
- render-svc: heartbeat persists last_disk_pct + disk_total_gb (migration 29); RenderNode
  model + node SELECT/scan carry them.
- admin: rewrite NodesTable to the real RenderNode shape (fixes a pre-existing items/V2Node
  mismatch that left the list empty) + a CPU/RAM/disk bars column + stale-heartbeat flag.
- assets-bundle ingestion: ProjectMediaBundle (jszip) auto-maps project.zip → project/scene
  image/demo/colour + music; PatchProject gains image/full_demo/shared_colors_svg.
- scan: RGBA (4-number) colours recognised + frshare single-int controls detected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 20:01:18 +03:30
parent 6661f53734
commit 0a7dd9b84c
18 changed files with 651 additions and 2834 deletions
@@ -0,0 +1,8 @@
-- =====================================================================
-- RENDER SCHEMA — node disk usage (reported by the agent heartbeat)
-- =====================================================================
SET search_path TO render, public;
ALTER TABLE render_nodes ADD COLUMN IF NOT EXISTS last_disk_pct INT;
ALTER TABLE render_nodes ADD COLUMN IF NOT EXISTS disk_total_gb INT;
+1 -1
View File
@@ -47,7 +47,7 @@
|---|---|
| `Final/` | `frfinal` — the mother render comp |
| `Edit/` | editable comps (any name); layers `frl_c(x)t(y)` (text) / `frl_c(x)m(y)` (media). `c(x)` = scene no., `(y)` = element index |
| `Share/` | `frshare` comp — `frd_<name>` layers: **colours** (RGBA, 4 numbers) + **design-selector** layers (a number **03** that shows/hides layers via expression). All user-editable. |
| `Share/` | `frshare` comp — `frd_<name>` layers, distinguished **by value**: a text layer holding **RGBA** (4 numbers, e.g. `253,226,228,255`) = a **shared colour**; a layer holding a **single integer 03** = a **shared control** (an expression reads it to switch a design variant — e.g. `frd_alladdfill=1` toggles *logo = image* vs *logo = fill-colour overlay*). All user-editable; expressions on the visible layers read these. |
| `Other/` | footage (video/image) files |
→ scanner derives scenes from the distinct `c<x>` in `frl_c(x)t/m(y)` layer names; element key = the full layer name; type `t`→Text, `m`→Media.
BIN
View File
Binary file not shown.
+177 -2664
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -32,6 +32,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.39.0",
"jszip": "^3.10.1",
"konva": "^9.3.22",
"lucide-react": "^1.16.0",
"next": "14.2.35",
@@ -398,6 +398,10 @@ public class TemplateService(ContentDbContext db)
if (req.FreeFps.HasValue) project.FreeFps = req.FreeFps.Value;
if (req.IsPublished.HasValue) project.IsPublished = req.IsPublished.Value;
if (req.Sort.HasValue) project.Sort = req.Sort.Value;
// media/colour fields (set by the assets-bundle ingestion)
if (req.Image != null) project.Image = req.Image;
if (req.FullDemo != null) project.FullDemo = req.FullDemo;
if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg;
project.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
@@ -276,7 +276,10 @@ public record PatchProjectRequest(
decimal? MaxDurationSec,
int? FreeFps,
bool? IsPublished,
int? Sort
int? Sort,
string? Image,
string? FullDemo,
string? SharedColorsSvg
);
// ── CMS ──────────────────────────────────────────────────────────────────────
+13
View File
@@ -37,6 +37,7 @@ import (
"github.com/flatrender/node-agent/internal/client"
"github.com/flatrender/node-agent/internal/config"
"github.com/flatrender/node-agent/internal/metrics"
"github.com/flatrender/node-agent/internal/runner"
)
@@ -324,10 +325,22 @@ func (a *Agent) heartbeatLoop(ctx context.Context) {
func (a *Agent) sendHeartbeat(ctx context.Context) {
status, jobID := a.getStatus()
// Live host metrics (Windows kernel32; stub elsewhere). CPU samples ~300ms.
cpu := metrics.CPUPercent(300 * time.Millisecond)
_, ramAvail := metrics.Memory()
diskPct, diskTotal := metrics.Disk(a.cfg.WorkDir)
aeRunning := a.cfg.AEPath != "" && a.isBusy()
req := client.HeartbeatRequest{
NodeID: a.cfg.NodeID,
Status: status,
CurrentJobID: jobID,
CPUPct: &cpu,
RAMAvailableMB: &ramAvail,
DiskUsedPct: &diskPct,
DiskTotalGB: &diskTotal,
AERunning: &aeRunning,
}
hbCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
@@ -122,6 +122,8 @@ type HeartbeatRequest struct {
Status string `json:"status"` // Ready | Busy
CPUPct *int `json:"cpu_pct,omitempty"`
RAMAvailableMB *int `json:"ram_available_mb,omitempty"`
DiskUsedPct *int `json:"disk_used_pct,omitempty"`
DiskTotalGB *int `json:"disk_total_gb,omitempty"`
AERunning *bool `json:"ae_running,omitempty"`
CurrentJobID *string `json:"current_job_id,omitempty"`
CacheUsedGB *int `json:"cache_used_gb,omitempty"`
@@ -0,0 +1,15 @@
//go:build !windows
// Non-Windows stub so the agent compiles in Linux CI / dev. Render nodes are
// Windows-only (After Effects), so real metrics live in metrics_windows.go.
package metrics
import (
"runtime"
"time"
)
func CPUPercent(interval time.Duration) int { time.Sleep(0); return 0 }
func Memory() (totalMB, availMB int) { return 0, 0 }
func Disk(path string) (usedPct, totalGB int) { return 0, 0 }
func Cores() int { return runtime.NumCPU() }
@@ -0,0 +1,114 @@
//go:build windows
// Package metrics reads host CPU / RAM / disk usage on Windows using only the
// stdlib (kernel32 via syscall) — no external dependency (GOPROXY is unavailable
// in this environment, and gopsutil isn't needed for these three numbers).
package metrics
import (
"runtime"
"syscall"
"time"
"unsafe"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW")
)
type memoryStatusEx struct {
cbSize uint32
dwMemoryLoad uint32
ullTotalPhys uint64
ullAvailPhys uint64
ullTotalPageFile uint64
ullAvailPageFile uint64
ullTotalVirtual uint64
ullAvailVirtual uint64
ullAvailExtendedVirtual uint64
}
type filetime struct{ low, high uint32 }
func (f filetime) u64() uint64 { return uint64(f.high)<<32 | uint64(f.low) }
type cpuSample struct{ idle, kernel, user uint64 }
func readCPU() (cpuSample, bool) {
var idle, kernel, user filetime
r, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)),
)
if r == 0 {
return cpuSample{}, false
}
return cpuSample{idle.u64(), kernel.u64(), user.u64()}, true
}
// CPUPercent samples system CPU times over `interval` and returns busy % (0-100).
// On Windows the "kernel" time INCLUDES idle, so total = kernel+user, busy = total-idle.
func CPUPercent(interval time.Duration) int {
a, ok := readCPU()
if !ok {
return 0
}
time.Sleep(interval)
b, ok := readCPU()
if !ok {
return 0
}
idleD := float64(b.idle - a.idle)
totalD := float64((b.kernel - a.kernel) + (b.user - a.user))
if totalD <= 0 {
return 0
}
busy := (totalD - idleD) / totalD * 100
if busy < 0 {
busy = 0
}
if busy > 100 {
busy = 100
}
return int(busy + 0.5)
}
// Memory returns (totalMB, availableMB).
func Memory() (totalMB, availMB int) {
var m memoryStatusEx
m.cbSize = uint32(unsafe.Sizeof(m))
if r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m))); r == 0 {
return 0, 0
}
return int(m.ullTotalPhys / 1024 / 1024), int(m.ullAvailPhys / 1024 / 1024)
}
// Disk returns (usedPct, totalGB) for the volume containing path (default C:\).
func Disk(path string) (usedPct, totalGB int) {
if path == "" {
path = "C:\\"
}
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
return 0, 0
}
var freeAvail, total, totalFree uint64
r, _, _ := procGetDiskFreeSpaceExW.Call(
uintptr(unsafe.Pointer(p)),
uintptr(unsafe.Pointer(&freeAvail)),
uintptr(unsafe.Pointer(&total)),
uintptr(unsafe.Pointer(&totalFree)),
)
if r == 0 || total == 0 {
return 0, 0
}
used := total - totalFree
return int(float64(used)/float64(total)*100 + 0.5), int(total / 1024 / 1024 / 1024)
}
// Cores returns the logical CPU count.
func Cores() int { return runtime.NumCPU() }
+18 -9
View File
@@ -63,18 +63,22 @@
function trim(s) { return String(s).replace(/^\s+|\s+$/g, ""); }
function isColor(s) {
if (!s) return false; s = trim(s);
return /^#?[0-9a-fA-F]{6}$/.test(s) || /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(s);
// hex, RGB (3 numbers) or RGBA (4 numbers — the FIX/frshare format, e.g. 253,226,228,255)
return /^#?[0-9a-fA-F]{6}$/.test(s) || /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(\s*,\s*\d{1,3})?$/.test(s);
}
function normColor(s) {
s = trim(s);
if (/^[0-9a-fA-F]{6}$/.test(s)) return "#" + s; // bare hex → add #
var m = s.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/); // r,g,b → #hex
var m = s.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*\d{1,3})?$/); // r,g,b[,a] → #hex (alpha dropped)
if (m) {
function h(n) { n = Math.max(0, Math.min(255, parseInt(n, 10))); var x = n.toString(16); return x.length === 1 ? "0" + x : x; }
return "#" + h(m[1]) + h(m[2]) + h(m[3]);
}
return s;
}
// A frshare control layer holds a single integer (e.g. 0-3) that an expression
// reads to switch a design variant (e.g. logo = image vs fill-colour overlay).
function isControl(s) { return /^\s*\d+\s*$/.test(String(s)); }
function justifyName(j) {
try {
@@ -139,25 +143,29 @@
return false;
}
// ── frshare colours (shared by all modes) ──────────────────────────────────
function readSharedColors(proj) {
var colors = [];
// ── frshare: shared colours (RGBA) + shared controls (single 0-3 number) ────
function readShared(proj) {
var colors = [], controls = [];
for (var i = 1; i <= proj.numItems; i++) {
var item = proj.item(i);
if (!(item instanceof CompItem) || item.name !== "frshare") continue;
for (var j = 1; j <= item.numLayers; j++) {
var cl = item.layer(j), ct = readText(cl);
if (ct && isColor(ct.text)) {
if (!ct) continue;
if (isColor(ct.text)) {
colors.push({ element_key: cl.name, title: cl.name, attr_value: "fill", default_color: normColor(ct.text), sort: j });
} else if (isControl(ct.text)) {
controls.push({ element_key: cl.name, title: cl.name, default_value: trim(ct.text), min: 0, max: 3, sort: j });
}
}
}
return colors;
return { colors: colors, controls: controls };
}
// ── FLEXIBLE / Mockup — each scene IS a comp; layers frl_/frd_ inside ───────
function scanFlexible(proj) {
var result = { source: "ae-jsx", render_comp: null, scenes: [], shared_colors: readSharedColors(proj) };
var sh = readShared(proj);
var result = { source: "ae-jsx", render_comp: null, scenes: [], shared_colors: sh.colors, shared_controls: sh.controls };
for (var i = 1; i <= proj.numItems; i++) {
var item = proj.item(i);
if (!(item instanceof CompItem)) continue;
@@ -177,7 +185,8 @@
// ── FIX / MusicVisualizer — scenes encoded in layer names frl_c(x)t/m(y) ────
function scanFix(proj) {
var result = { source: "ae-jsx", render_comp: "frfinal", scenes: [], shared_colors: readSharedColors(proj) };
var sh = readShared(proj);
var result = { source: "ae-jsx", render_comp: "frfinal", scenes: [], shared_colors: sh.colors, shared_controls: sh.controls };
var sceneMap = {}, order = [];
var re = /^frl_c(\d+)([tm])(\d+)$/;
for (var i = 1; i <= proj.numItems; i++) {
+10 -7
View File
@@ -64,7 +64,7 @@ func (s *Store) GetNodeByID(ctx context.Context, id uuid.UUID) (*models.RenderNo
lifetime_task_count, lifetime_crash_count, consecutive_failures,
priority, is_active, accepts_new_jobs,
last_maintenance_at, next_maintenance_at, maintenance_reason,
cached_template_md5s, cache_used_gb, created_at, updated_at
cached_template_md5s, cache_used_gb, created_at, updated_at, last_disk_pct, disk_total_gb
FROM render.render_nodes WHERE id = $1`, id)
if err != nil {
return nil, err
@@ -158,13 +158,15 @@ func (s *Store) UpdateNodeHeartbeat(ctx context.Context, nodeID uuid.UUID, req *
last_heartbeat_at = NOW(),
last_cpu_pct = $2,
last_ram_available_mb = $3,
ae_running = COALESCE($4, ae_running),
current_job_id = $5,
current_frame_job_id = $6,
last_disk_pct = $4,
disk_total_gb = COALESCE($5, disk_total_gb),
ae_running = COALESCE($6, ae_running),
current_job_id = $7,
current_frame_job_id = $8,
updated_at = NOW()
WHERE id = $7`,
req.Status, req.CPUPct, req.RAMAvailableMB, req.AERunning,
req.CurrentJobID, nil, nodeID,
WHERE id = $9`,
req.Status, req.CPUPct, req.RAMAvailableMB, req.DiskUsedPct, req.DiskTotalGB,
req.AERunning, req.CurrentJobID, nil, nodeID,
)
return err
}
@@ -846,6 +848,7 @@ func scanNodes(rows pgx.Rows) ([]*models.RenderNode, error) {
&n.Priority, &n.IsActive, &n.AcceptsNewJobs,
&n.LastMaintenanceAt, &n.NextMaintenanceAt, &n.MaintenanceReason,
&n.CachedTemplateMD5s, &n.CacheUsedGB, &n.CreatedAt, &n.UpdatedAt,
&n.LastDiskPct, &n.DiskTotalGB,
); err != nil {
return nil, err
}
@@ -112,6 +112,8 @@ type RenderNode struct {
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
LastCPUPct *int `json:"last_cpu_pct,omitempty"`
LastRAMAvailableMB *int `json:"last_ram_available_mb,omitempty"`
LastDiskPct *int `json:"last_disk_pct,omitempty"`
DiskTotalGB *int `json:"disk_total_gb,omitempty"`
AERunning bool `json:"ae_running"`
LifetimeTaskCount int64 `json:"lifetime_task_count"`
LifetimeCrashCount int `json:"lifetime_crash_count"`
@@ -369,6 +371,8 @@ type NodeHeartbeatRequest struct {
Status string `json:"status"`
CPUPct *int `json:"cpu_pct"`
RAMAvailableMB *int `json:"ram_available_mb"`
DiskUsedPct *int `json:"disk_used_pct"`
DiskTotalGB *int `json:"disk_total_gb"`
AERunning *bool `json:"ae_running"`
CurrentJobID *uuid.UUID `json:"current_job_id"`
CurrentFrame *int `json:"current_frame"`
+4 -20
View File
@@ -1,31 +1,15 @@
import { getTranslations } from "next-intl/server";
import { adminGet } from "@/lib/api/admin-gateway";
import { NodesTable } from "@/components/admin/NodesTable";
import { NodesTable, type RenderNode } from "@/components/admin/NodesTable";
export const dynamic = "force-dynamic";
export const revalidate = 0;
interface V2Node {
id: string;
name: string;
status: "Online" | "Busy" | "Offline" | "Draining";
last_heartbeat: string;
active_job_id: string | null;
slots_total: number;
slots_used: number;
version: string | null;
tags: string[] | null;
}
interface V2NodeList {
items: V2Node[];
total: number;
}
export default async function AdminNodesPage() {
const data = await adminGet<V2NodeList>("/v1/nodes?pageSize=100");
const nodes = data?.items ?? [];
// render-svc returns { data: RenderNode[] }
const res = await adminGet<{ data: RenderNode[] }>("/v1/nodes");
const nodes = res?.data ?? [];
const t = await getTranslations("auto.appAdminNodesPage");
return (
+100 -107
View File
@@ -1,45 +1,81 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { apiFetch } from "@/lib/api/fetch";
import { useRouter } from "next/navigation";
import { NodeDetail } from "@/components/admin/NodeDetail";
interface V2Node {
// Matches render-svc RenderNode (snake_case JSON).
export interface RenderNode {
id: string;
name: string;
status: "Online" | "Busy" | "Offline" | "Draining";
last_heartbeat: string;
active_job_id: string | null;
slots_total: number;
slots_used: number;
version: string | null;
tags: string[] | null;
region: string;
status: string; // Ready | Busy | Offline | Maintenance | Crashed | Updating | Disabled
current_ae_version?: string | null;
node_kind?: string | null;
ram_gb?: number | null;
cpu_cores?: number | null;
last_heartbeat_at?: string | null;
current_job_id?: string | null;
last_cpu_pct?: number | null;
last_ram_available_mb?: number | null;
last_disk_pct?: number | null;
disk_total_gb?: number | null;
ae_running?: boolean;
}
const STATUS_COLORS: Record<V2Node["status"], string> = {
Online: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
const STATUS_COLORS: Record<string, string> = {
Ready: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
Busy: "bg-blue-500/20 text-blue-300 border-blue-500/30",
Offline: "bg-gray-500/20 text-gray-400 border-gray-500/30",
Draining: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
Maintenance: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
Crashed: "bg-red-500/20 text-red-300 border-red-500/30",
Updating: "bg-purple-500/20 text-purple-300 border-purple-500/30",
Disabled: "bg-gray-500/20 text-gray-500 border-gray-500/30",
};
function heartbeatAge(iso: string): string {
function heartbeatAge(iso?: string | null): string {
if (!iso) return "—";
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
if (diff < 0) return "now";
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
return `${Math.floor(diff / 3600)}h`;
}
const isStale = (iso?: string | null) => !iso || Date.now() - new Date(iso).getTime() > 30000;
function Bar({ pct }: { pct: number }) {
const color = pct >= 90 ? "bg-red-500" : pct >= 75 ? "bg-amber-500" : "bg-emerald-500";
return (
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[#1e2235]">
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.max(0, Math.min(100, pct))}%` }} />
</div>
);
}
const AE_VERSIONS = ["2025", "2024", "2023", "2022", "2021", "2020"];
function Metric({ label, pct, sub }: { label: string; pct: number | null | undefined; sub?: string }) {
return (
<div className="flex items-center gap-2">
<span className="w-9 text-[10px] text-gray-500">{label}</span>
{pct == null ? (
<span className="text-[11px] text-gray-600"></span>
) : (
<>
<Bar pct={pct} />
<span className="w-8 tabular-nums text-[11px] text-gray-300">{pct}%</span>
{sub && <span className="text-[10px] text-gray-600">{sub}</span>}
</>
)}
</div>
);
}
const AE_VERSIONS = ["2026", "2025", "2024", "2023", "2022", "2021", "2020"];
const NODE_KINDS = ["Shared", "Dedicated", "Spot"];
const emptyNode = { name: "", region: "", node_ip: "", worker_port: 8088, current_ae_version: "2024", node_kind: "Dedicated", ram_gb: "", cpu_cores: "", priority: 5 };
const emptyNode = { name: "", region: "", node_ip: "", worker_port: 8088, current_ae_version: "2026", node_kind: "Dedicated", ram_gb: "", cpu_cores: "", priority: 5 };
const fldCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
export function NodesTable({ nodes }: { nodes: V2Node[] }) {
const t = useTranslations("auto.componentsAdminNodesTable");
export function NodesTable({ nodes }: { nodes: RenderNode[] }) {
const router = useRouter();
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [showAdd, setShowAdd] = useState(false);
@@ -51,7 +87,7 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
const action = async (nodeId: string, endpoint: string) => {
setLoading((p) => ({ ...p, [nodeId]: true }));
try {
await apiFetch(`/api/admin/nodes/${nodeId}/${endpoint}`, { method: "POST" });
await fetch(`/api/admin/nodes/${nodeId}/${endpoint}`, { method: "POST" });
router.refresh();
} finally {
setLoading((p) => ({ ...p, [nodeId]: false }));
@@ -129,120 +165,77 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
</div>
);
if (nodes.length === 0) {
return (
<div className="space-y-3">
<div className="flex justify-end">{addBtn}</div>
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
{t("emptyState")}
</div>
{addModal}
</div>
);
}
const detailModal = detailNode && (
<NodeDetail nodeId={detailNode.id} nodeName={detailNode.name} onClose={() => setDetailNode(null)} />
);
return (
<div className="space-y-3">
<div className="space-y-3" dir="rtl">
<div className="flex justify-end">{addBtn}</div>
{addModal}
{detailModal}
<div className="overflow-hidden rounded-xl border border-[#1e2235]">
{nodes.length === 0 ? (
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
هنوز نودی ثبت نشده.
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-[#1e2235]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">{t("colNode")}</th>
<th className="px-4 py-3">{t("colStatus")}</th>
<th className="px-4 py-3">{t("colSlots")}</th>
<th className="px-4 py-3">{t("colHeartbeat")}</th>
<th className="px-4 py-3">{t("colActiveJob")}</th>
<th className="px-4 py-3">{t("colTags")}</th>
<th className="px-4 py-3">{t("colActions")}</th>
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-right text-xs font-medium text-gray-500">
<th className="px-4 py-3">نود</th>
<th className="px-4 py-3">وضعیت</th>
<th className="px-4 py-3">منابع (CPU / RAM / دیسک)</th>
<th className="px-4 py-3">ضربان</th>
<th className="px-4 py-3">کار فعلی</th>
<th className="px-4 py-3 text-left">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
{nodes.map((node) => (
<tr key={node.id} className="hover:bg-[#0f1120]/60 transition-colors">
{nodes.map((node) => {
const ramUsedPct = node.ram_gb && node.last_ram_available_mb != null
? Math.round((1 - node.last_ram_available_mb / (node.ram_gb * 1024)) * 100) : null;
const stale = isStale(node.last_heartbeat_at);
return (
<tr key={node.id} className="transition-colors hover:bg-[#0f1120]/60">
<td className="px-4 py-3">
<div className="font-medium text-white">{node.name}</div>
<div className="text-[11px] text-gray-600 font-mono mt-0.5">{node.id.slice(0, 8)}</div>
<div className="mt-0.5 text-[11px] text-gray-600">{node.region} · AE {node.current_ae_version ?? "—"} · {node.node_kind}</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${STATUS_COLORS[node.status]}`}>
{node.status}
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${STATUS_COLORS[node.status] ?? STATUS_COLORS.Offline}`}>
{node.status}{node.ae_running ? " · AE" : ""}
</span>
</td>
<td className="px-4 py-3 tabular-nums text-gray-300">
{node.slots_used} / {node.slots_total}
</td>
<td className="px-4 py-3 text-gray-400">
{heartbeatAge(node.last_heartbeat)}
</td>
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
{node.active_job_id ? node.active_job_id.slice(0, 12) + "…" : "—"}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(node.tags ?? []).map((t) => (
<span key={t} className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">
{t}
</span>
))}
<div className="space-y-1">
<Metric label="CPU" pct={node.last_cpu_pct ?? null} />
<Metric label="RAM" pct={ramUsedPct} sub={node.last_ram_available_mb != null ? `${node.last_ram_available_mb}MB آزاد` : undefined} />
<Metric label="دیسک" pct={node.last_disk_pct ?? null} sub={node.disk_total_gb ? `از ${node.disk_total_gb}GB` : undefined} />
</div>
</td>
<td className={`px-4 py-3 text-xs ${stale ? "text-red-400" : "text-gray-400"}`}>
{heartbeatAge(node.last_heartbeat_at)}{stale ? " ⚠" : ""}
</td>
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
{node.current_job_id ? node.current_job_id.slice(0, 12) + "…" : "—"}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => setDetailNode({ id: node.id, name: node.name })}
className="rounded px-2.5 py-1 text-xs text-gray-300 border border-[#262b40] hover:bg-[#161a2e] transition-colors"
>
جزئیات
</button>
<button
onClick={() => action(node.id, "drain")}
disabled={loading[node.id] || node.status === "Offline"}
className="rounded px-2.5 py-1 text-xs text-yellow-300 border border-yellow-500/30 hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionDrain")}
</button>
<button
onClick={() => action(node.id, "restart")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-blue-300 border border-blue-500/30 hover:bg-blue-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionRestart")}
</button>
<button
onClick={() => action(node.id, "close-ae")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-orange-300 border border-orange-500/30 hover:bg-orange-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionCloseAe")}
</button>
<button
onClick={() => action(node.id, "release")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionRelease")}
</button>
<button
onClick={() => deleteNode(node.id)}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-red-400 border border-red-600/50 hover:bg-red-600/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
حذف
</button>
<div className="flex flex-wrap gap-1.5">
<button onClick={() => setDetailNode({ id: node.id, name: node.name })} className="rounded border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e]">جزئیات</button>
<button onClick={() => action(node.id, "restart")} disabled={loading[node.id]} className="rounded border border-blue-500/30 px-2.5 py-1 text-xs text-blue-300 hover:bg-blue-500/10 disabled:opacity-40">ریاستارت</button>
<button onClick={() => action(node.id, "close-ae")} disabled={loading[node.id]} className="rounded border border-orange-500/30 px-2.5 py-1 text-xs text-orange-300 hover:bg-orange-500/10 disabled:opacity-40">بستن AE</button>
<button onClick={() => action(node.id, "release")} disabled={loading[node.id]} className="rounded border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-40">آزادسازی</button>
<button onClick={() => deleteNode(node.id)} disabled={loading[node.id]} className="rounded border border-red-600/50 px-2.5 py-1 text-xs text-red-400 hover:bg-red-600/20 disabled:opacity-40">حذف</button>
</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import JSZip from "jszip";
// Auto-ingest the project assets bundle (project.zip): extract in-browser, upload
// each file, and map by name → project + scene fields (the §11.5 convention).
// After this the admin can still modify any of them in the scene editor.
//
// p.jpg/png/webp → project image p.mp4 → project demo p.svg → project colour svg
// s{i}.jpg/png → scene[i] image s{i}.mp4 → scene[i] demo s{i}.svg → scene[i] colour svg
// <name>.mp3 (not "sfx") → project default music (stored as a project asset)
// (s{i} maps to the i-th scene by sort order — scenes must exist first, via the scan)
interface Scene {
id: string; key: string; title: string; localized_title?: string | null; scene_type: string;
image?: string | null; demo?: string | null; scene_color_svg?: string | null; snapshot_url?: string | null;
generate_kf: boolean; default_duration_sec?: number | null; min_duration_sec?: number | null;
max_duration_sec?: number | null; overlap_at_end_sec: number; can_handle_duration: boolean;
manual_color_selection: boolean; sort: number; is_active: boolean;
}
const isImg = (e: string) => /^(jpg|jpeg|png|webp|gif)$/.test(e);
export function ProjectMediaBundle({ projectId, onDone }: { projectId: string; onDone: () => void }) {
const [busy, setBusy] = useState(false);
const [log, setLog] = useState<string[]>([]);
const [err, setErr] = useState<string | null>(null);
const add = (m: string) => setLog((l) => [...l, m]);
const uploadBlob = async (filename: string, blob: Blob): Promise<string> => {
const fd = new FormData();
fd.append("file", new File([blob], filename));
const r = await fetch("/api/admin/files/upload", { method: "POST", body: fd });
const d = await r.json().catch(() => null);
if (!r.ok || !d?.url) throw new Error(`بارگذاری «${filename}» ناموفق بود`);
return d.url as string;
};
const run = async (file: File) => {
setBusy(true); setErr(null); setLog([]);
try {
const zip = await JSZip.loadAsync(file);
// scenes (sorted by sort) — s{i} maps to the i-th
const sr = await fetch(`/api/admin/resource/scenes?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
const scenes: Scene[] = (Array.isArray(sr) ? sr : sr?.data ?? []).slice().sort((a: Scene, b: Scene) => a.sort - b.sort);
add(`صحنه‌های موجود: ${scenes.length}`);
const projPatch: Record<string, string> = {};
const sceneOver: Record<number, { image?: string; demo?: string; scene_color_svg?: string }> = {};
let music: { url: string; name: string } | null = null;
const entries = Object.values(zip.files).filter((f) => !f.dir && !/(^|\/)(__MACOSX|\._)/.test(f.name));
for (const entry of entries) {
const base = (entry.name.split("/").pop() || "").toLowerCase();
const ext = (base.match(/\.([^.]+)$/)?.[1]) || "";
const stem = base.replace(/\.[^.]+$/, "");
const blob = await entry.async("blob");
const url = await uploadBlob(base, blob);
add(`${base}`);
const sm = stem.match(/^s(\d+)$/);
if (stem === "p") {
if (isImg(ext)) projPatch.image = url;
else if (ext === "mp4") projPatch.full_demo = url;
else if (ext === "svg") projPatch.shared_colors_svg = url;
} else if (sm) {
const i = parseInt(sm[1], 10);
sceneOver[i] = sceneOver[i] || {};
if (isImg(ext)) sceneOver[i].image = url;
else if (ext === "mp4") sceneOver[i].demo = url;
else if (ext === "svg") sceneOver[i].scene_color_svg = url;
} else if (ext === "mp3" && stem !== "sfx") {
music = { url, name: base };
}
// demo.mp4 / sfx / other → ignored (sfx ships inside the render footage)
}
// project fields
if (Object.keys(projPatch).length) {
await fetch(`/api/admin/resource/projects/${projectId}`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(projPatch),
});
add(`پروژه به‌روزرسانی شد (${Object.keys(projPatch).join(", ")})`);
}
// scene fields (PUT full scene with the new media merged in)
let sceneHits = 0;
for (const key of Object.keys(sceneOver)) {
const i = parseInt(key, 10);
const scene = scenes[i - 1];
if (!scene) { add(`⚠ صحنهٔ ${i} وجود ندارد — رد شد`); continue; }
const o = sceneOver[i];
const body = {
key: scene.key, title: scene.title, localized_title: scene.localized_title ?? null, scene_type: scene.scene_type,
image: o.image ?? scene.image ?? null, demo: o.demo ?? scene.demo ?? null,
scene_color_svg: o.scene_color_svg ?? scene.scene_color_svg ?? null, snapshot_url: scene.snapshot_url ?? null,
generate_kf: scene.generate_kf, default_duration_sec: scene.default_duration_sec,
min_duration_sec: scene.min_duration_sec, max_duration_sec: scene.max_duration_sec,
overlap_at_end_sec: scene.overlap_at_end_sec ?? 0, can_handle_duration: scene.can_handle_duration,
manual_color_selection: scene.manual_color_selection, sort: scene.sort, is_active: scene.is_active,
};
await fetch(`/api/admin/resource/scenes/${scene.id}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
sceneHits++;
}
if (sceneHits) add(`${sceneHits.toLocaleString("fa-IR")} صحنه با رسانه به‌روزرسانی شد`);
// music → project asset
if (music) {
await fetch(`/api/admin/resource/projects/${projectId}/assets`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: music.name, kind: "audio", url: music.url, sort: 0 }),
});
add(`موسیقی افزوده شد: ${music.name}`);
}
add("✓ بستهٔ رسانه با موفقیت اعمال شد");
onDone();
} catch (e) {
setErr(e instanceof Error ? e.message : "اعمال بستهٔ رسانه ناموفق بود");
} finally {
setBusy(false);
}
};
return (
<div className="rounded-lg border border-[#262b40] p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-[11px] font-medium text-gray-300">بستهٔ رسانه (zip)</p>
<p className="text-[10px] text-gray-500">تصاویر/دموهای صحنهها و پروژه + موسیقی را خودکار اعمال میکند (p.jpg، p.mp4، s۱.jpg). بعداً قابل ویرایش است.</p>
</div>
<label className="shrink-0 cursor-pointer rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">
{busy ? "در حال اعمال…" : "بارگذاری بستهٔ رسانه"}
<input type="file" accept=".zip" className="hidden" disabled={busy}
onChange={(e) => { const f = e.target.files?.[0]; if (f) run(f); e.currentTarget.value = ""; }} />
</label>
</div>
{err && <p className="mt-2 rounded bg-red-500/10 px-2 py-1 text-[11px] text-red-300">{err}</p>}
{log.length > 0 && (
<ul className="mt-2 max-h-32 space-y-0.5 overflow-y-auto text-[10px] text-gray-500" dir="ltr">
{log.map((l, i) => <li key={i}>{l}</li>)}
</ul>
)}
</div>
);
}
+2
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { AdminThumb } from "@/components/admin/AdminThumb";
import { FileUploadField } from "@/components/admin/FileUploadField";
import { ProjectAssets } from "@/components/admin/ProjectAssets";
import { ProjectMediaBundle } from "@/components/admin/ProjectMediaBundle";
import { ProjectScenes } from "@/components/admin/ProjectScenes";
interface Proj {
@@ -262,6 +263,7 @@ export function ProjectsAdmin() {
<p className="mt-1 text-[11px] text-gray-500">برای پروژههایی که فوتیج/فونت دارند، کل پروژه را بهصورت فایل zip آپلود کنید؛ هنگام رندر روی نود استخراج میشود.</p>
{aepMsg && <p className="mt-1 text-[11px] text-indigo-300">{aepMsg}</p>}
</div>
<ProjectMediaBundle projectId={openAssets.id} onDone={() => load()} />
<ProjectAssets projectId={openAssets.id} />
</div>
<div className="mt-4 flex justify-end">