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:
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user