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
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:
@@ -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;
|
||||
@@ -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 **0–3** 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 0–3** = 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.
|
||||
|
||||
Binary file not shown.
Generated
+177
-2664
File diff suppressed because it is too large
Load Diff
@@ -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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
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() }
|
||||
@@ -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++) {
|
||||
|
||||
@@ -64,8 +64,8 @@ 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
|
||||
FROM render.render_nodes WHERE id = $1`, id)
|
||||
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"`
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+121
-128
@@ -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]">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
|
||||
{nodes.map((node) => (
|
||||
<tr key={node.id} className="hover:bg-[#0f1120]/60 transition-colors">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{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-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) => {
|
||||
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="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] ?? STATUS_COLORS.Offline}`}>
|
||||
{node.status}{node.ae_running ? " · AE" : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user