fix admin screen: move data destructuring inside components, normalize field names: screens-admin.jsx
This commit is contained in:
parent
406f28c663
commit
0342aa0a5a
1 changed files with 189 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue