fix admin screen: move data destructuring inside components, normalize field names: screens-admin.jsx

This commit is contained in:
Zac Gaetano 2026-05-22 10:15:42 -04:00
parent 406f28c663
commit 0342aa0a5a

View file

@ -1,8 +1,24 @@
// screens-admin.jsx Users, Tokens, Containers, Cluster (graph), Settings
const { USERS, TOKENS_LIST, CONTAINERS, NODES } = window.ZAMPP_DATA;
function _normalizeNode(n, x, y) {
return {
id: n.id || n.hostname || n.name || 'node',
role: n.role || 'worker',
status: n.status || (n.online ? 'online' : 'offline'),
ip: n.ip || n.ip_address || '—',
version: n.version || '—',
uptime: n.uptime || '—',
cpu: n.cpu || n.cpu_percent || 0,
mem: n.mem || n.memory_used || n.memory_used_gb || 0,
memTotal: n.memTotal || n.mem_total || n.memory_total || n.memory_total_gb || 0,
gpus: n.gpus || (n.gpu_count ? Array(n.gpu_count).fill('GPU') : []),
devices: n.devices || n.capture_devices || [],
x, y,
};
}
function Users() {
const { USERS } = window.ZAMPP_DATA;
const [tab, setTab] = React.useState("users");
return (
<div className="page">
@ -15,8 +31,8 @@ function Users() {
<div className="page-body">
<div className="tab-group" style={{ width: "fit-content", marginBottom: 12 }}>
<button className={tab === "users" ? "active" : ""} onClick={() => setTab("users")}>Users · {USERS.length}</button>
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups · 4</button>
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies · 7</button>
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups</button>
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies</button>
</div>
<div className="panel">
<div className="user-row head">
@ -26,20 +42,23 @@ function Users() {
<div>Last active</div>
<div></div>
</div>
{USERS.length === 0 && (
<div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
)}
{USERS.map(u => (
<div key={u.id} className="user-row">
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.avatar) }}>{u.avatar}</div>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.display}</div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>@{u.username}</div>
</div>
</div>
<div><span className={`badge ${u.role === "admin" ? "purple" : u.role === "service" ? "neutral" : "accent"}`}>{u.role}</span></div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{u.groups.map(g => <span key={g} className="badge outline" style={{ textTransform: "lowercase" }}>{g}</span>)}
{(u.groups || []).map(g => <span key={g} className="badge outline" style={{ textTransform: "lowercase" }}>{g}</span>)}
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>{u.last}</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>{u.lastSeen}</div>
<div><button className="icon-btn"><Icon name="more" /></button></div>
</div>
))}
@ -295,73 +314,133 @@ function CalcSlider({ label, value, onChange, min, max, step = 1, unit }) {
}
function Containers() {
const [containers, setContainers] = React.useState(null);
function load() {
setContainers(null);
window.ZAMPP_API.fetch('/cluster/containers')
.then(data => setContainers(Array.isArray(data) ? data : (data.containers || [])))
.catch(() => setContainers([]));
}
React.useEffect(() => { load(); }, []);
const running = (containers || []).filter(c => c.state === 'running').length;
return (
<div className="page">
<div className="page-header">
<h1>Containers</h1>
<span className="subtitle">Docker Compose services across the cluster</span>
<div className="spacer" />
<div className="status-pip">
<span className="dot" />
<span>{CONTAINERS.filter(c => c.state === "running").length} / {CONTAINERS.length} running</span>
</div>
<button className="btn ghost sm"><Icon name="refresh" />Refresh</button>
{containers !== null && containers.length > 0 && (
<div className="status-pip">
<span className="dot" />
<span>{running} / {containers.length} running</span>
</div>
)}
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div className="panel">
<div className="container-row head">
<div>Container</div>
<div>Image</div>
<div>State</div>
<div>CPU</div>
<div>Memory</div>
<div>Ports</div>
<div></div>
{containers === null && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading</div>
)}
{containers !== null && containers.length === 0 && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🐳</div>
<div style={{ fontWeight: 500, fontSize: 14 }}>No container data available</div>
<div style={{ fontSize: 12, marginTop: 6 }}>Container metrics endpoint not yet wired</div>
</div>
{CONTAINERS.map(c => (
<div key={c.id} className="container-row">
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{c.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>up {c.uptime}</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-2)" }}>{c.image}</div>
<div>
<span className="badge success"><StatusDot status="online" /> RUNNING</span>
{c.healthy && <span style={{ fontSize: 10.5, color: "var(--success)", marginLeft: 6 }}>healthy</span>}
</div>
<div className="mono" style={{ fontSize: 11.5 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 40, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${Math.min(c.cpu * 4, 100)}%`, height: "100%", background: "var(--accent)" }} />
)}
{containers !== null && containers.length > 0 && (
<div className="panel">
<div className="container-row head">
<div>Container</div>
<div>Image</div>
<div>State</div>
<div>CPU</div>
<div>Memory</div>
<div>Ports</div>
<div></div>
</div>
{containers.map(c => (
<div key={c.id || c.name} className="container-row">
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{c.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>up {c.uptime}</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-2)" }}>{c.image}</div>
<div>
<span className="badge success"><StatusDot status="online" /> RUNNING</span>
{c.healthy && <span style={{ fontSize: 10.5, color: "var(--success)", marginLeft: 6 }}>healthy</span>}
</div>
<div className="mono" style={{ fontSize: 11.5 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 40, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${Math.min((c.cpu || 0) * 4, 100)}%`, height: "100%", background: "var(--accent)" }} />
</div>
<span>{(c.cpu || 0).toFixed(1)}%</span>
</div>
<span>{c.cpu.toFixed(1)}%</span>
</div>
<div className="mono" style={{ fontSize: 11.5 }}>{c.mem} MB</div>
<div className="mono" style={{ fontSize: 10.5, color: "var(--text-3)" }}>{c.ports}</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn ghost sm">Logs</button>
<button className="btn ghost sm">Restart</button>
</div>
</div>
<div className="mono" style={{ fontSize: 11.5 }}>{c.mem} MB</div>
<div className="mono" style={{ fontSize: 10.5, color: "var(--text-3)" }}>{c.ports}</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn ghost sm">Logs</button>
<button className="btn ghost sm">Restart</button>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</div>
);
}
function Cluster() {
const rawNodes = window.ZAMPP_DATA.NODES;
const nodesArr = Array.isArray(rawNodes) ? rawNodes : (rawNodes?.nodes || []);
const NODES = React.useMemo(() => {
if (!nodesArr.length) return [];
const primaryRaw = nodesArr.find(n => n.role === 'primary') || nodesArr[0];
const others = nodesArr.filter(n => n !== primaryRaw);
const primary = _normalizeNode(primaryRaw, 0.5, 0.46);
const positioned = others.map((n, i) => {
const angle = others.length <= 1
? Math.PI / 2
: (i / others.length) * 2 * Math.PI - Math.PI / 2;
return _normalizeNode(n, 0.5 + 0.32 * Math.cos(angle), 0.46 + 0.35 * Math.sin(angle));
});
return [primary, ...positioned];
}, []);
const [hovered, setHovered] = React.useState(null);
const [selected, setSelected] = React.useState(NODES[0]);
const [selected, setSelected] = React.useState(NODES[0] || null);
const W = 720, H = 460;
const primary = NODES.find(n => n.role === "primary");
if (!NODES.length) {
return (
<div className="page">
<div className="page-header">
<h1>Cluster</h1>
<div className="spacer" />
<button className="btn ghost sm"><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>No cluster nodes available</div>
</div>
</div>
);
}
const primary = NODES.find(n => n.role === 'primary') || NODES[0];
const edges = NODES.filter(n => n.id !== primary.id).map(n => ({
from: primary,
to: n,
alive: n.status === "online",
alive: n.status === 'online',
}));
const sel = selected || NODES[0];
return (
<div className="page">
@ -378,22 +457,18 @@ function Cluster() {
<div className="stat-card">
<div className="label"><Icon name="cluster" size={12} />Nodes</div>
<div className="value">{NODES.length}</div>
<div className="delta">across 2 regions</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="cpu" size={12} />Total CPU</div>
<div className="value">320 <span style={{ fontSize: 14, color: "var(--text-3)" }}>cores</span></div>
<div className="delta">14% utilized</div>
<div className="label"><Icon name="cpu" size={12} />Avg CPU</div>
<div className="value">{Math.round(NODES.reduce((a, n) => a + (n.cpu || 0), 0) / NODES.length)} <span style={{ fontSize: 14, color: "var(--text-3)" }}>%</span></div>
</div>
<div className="stat-card">
<div className="label"><Icon name="gpu" size={12} />GPUs</div>
<div className="value">5</div>
<div className="delta">3 idle · 2 transcoding</div>
<div className="value">{NODES.reduce((a, n) => a + n.gpus.length, 0)}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="hdd" size={12} />Memory</div>
<div className="value">243 <span style={{ fontSize: 14, color: "var(--text-3)" }}>GB</span></div>
<div className="delta">15% utilized</div>
<div className="label"><Icon name="hdd" size={12} />Avg Memory</div>
<div className="value">{Math.round(NODES.reduce((a, n) => a + (n.mem || 0), 0) / NODES.length)} <span style={{ fontSize: 14, color: "var(--text-3)" }}>GB</span></div>
</div>
</div>
@ -403,7 +478,6 @@ function Cluster() {
<span style={{ fontWeight: 600, fontSize: 13 }}>Topology</span>
<div className="tab-group">
<button className="active">Graph</button>
<button>Map</button>
<button>List</button>
</div>
</div>
@ -440,8 +514,7 @@ function Cluster() {
})}
{NODES.map(n => {
const cx = n.x * W, cy = n.y * H;
const isHovered = hovered === n.id;
const isSelected = selected.id === n.id;
const isSelected = sel && sel.id === n.id;
const color = n.status === "online" ? "var(--success)" : "var(--text-4)";
return (
<g key={n.id} transform={`translate(${cx}, ${cy})`}
@ -466,61 +539,65 @@ function Cluster() {
</svg>
</div>
<div className="panel">
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
<StatusDot status={selected.status} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{selected.id}</span>
<span className={`badge ${selected.role === "primary" ? "accent" : "neutral"}`}>{selected.role}</span>
</div>
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 10 }}>
<DetailRow k="Status" v={<span style={{ color: selected.status === "online" ? "var(--success)" : "var(--text-3)" }}>{selected.status}</span>} />
<DetailRow k="IP" v={selected.ip} mono />
<DetailRow k="Version" v={selected.version} mono />
<DetailRow k="Uptime" v={selected.uptime} mono />
<DetailRow k="CPU" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${selected.cpu}%`, height: "100%", background: "var(--accent)" }} />
</div>
<span className="mono">{selected.cpu}%</span>
</div>
} />
<DetailRow k="Memory" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${(selected.mem / selected.memTotal) * 100}%`, height: "100%", background: "var(--purple)" }} />
</div>
<span className="mono">{selected.mem} / {selected.memTotal} GB</span>
</div>
} />
{selected.gpus.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>GPUs ({selected.gpus.length})</div>
{selected.gpus.map((g, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} style={{ color: "var(--text-3)" }} />
<span>{g}</span>
{sel && (
<div className="panel">
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
<StatusDot status={sel.status} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{sel.id}</span>
<span className={`badge ${sel.role === "primary" ? "accent" : "neutral"}`}>{sel.role}</span>
</div>
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 10 }}>
<DetailRow k="Status" v={<span style={{ color: sel.status === "online" ? "var(--success)" : "var(--text-3)" }}>{sel.status}</span>} />
<DetailRow k="IP" v={sel.ip} mono />
<DetailRow k="Version" v={sel.version} mono />
<DetailRow k="Uptime" v={sel.uptime} mono />
<DetailRow k="CPU" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${sel.cpu}%`, height: "100%", background: "var(--accent)" }} />
</div>
))}
</div>
)}
{selected.devices && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>Capture devices</div>
{selected.devices.map((d, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4 }}>
<Icon name="video" size={11} style={{ color: "var(--text-3)", marginRight: 6 }} />{d}
<span className="mono">{sel.cpu}%</span>
</div>
} />
{sel.memTotal > 0 && (
<DetailRow k="Memory" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${(sel.mem / sel.memTotal) * 100}%`, height: "100%", background: "var(--purple)" }} />
</div>
<span className="mono">{sel.mem} / {sel.memTotal} GB</span>
</div>
))}
} />
)}
{sel.gpus.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>GPUs ({sel.gpus.length})</div>
{sel.gpus.map((g, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} style={{ color: "var(--text-3)" }} />
<span>{g}</span>
</div>
))}
</div>
)}
{sel.devices && sel.devices.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>Capture devices</div>
{sel.devices.map((d, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4 }}>
<Icon name="video" size={11} style={{ color: "var(--text-3)", marginRight: 6 }} />{d}
</div>
))}
</div>
)}
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button className="btn ghost sm">Logs</button>
<button className="btn ghost sm">Drain</button>
{sel.role !== "primary" && <button className="btn danger sm">Remove</button>}
</div>
)}
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button className="btn ghost sm">Logs</button>
<button className="btn ghost sm">Drain</button>
{selected.role !== "primary" && <button className="btn danger sm">Remove</button>}
</div>
</div>
</div>
)}
</div>
</div>
</div>
@ -582,7 +659,7 @@ function Settings() {
icon="gpu"
title="GPU / Transcoding"
sub="NVIDIA NVENC acceleration for proxy generation and transcoding jobs"
tag={<span className="badge accent">3 GPUs available</span>}
tag={<span className="badge accent">GPUs available</span>}
>
<label className="checkbox-row">
<input type="checkbox" defaultChecked /> Enable GPU-accelerated transcoding