screens-admin: wire all buttons — invite user, export CSV, cluster refresh, container logs/restart, node drain/remove

This commit is contained in:
Zac Gaetano 2026-05-22 12:27:02 -04:00
parent d00e1c666e
commit 451bed834f

View file

@ -17,20 +17,101 @@ function _normalizeNode(n, x, y) {
};
}
function InviteUserModal({ onCreated, onClose }) {
const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer' });
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = () => {
if (!form.username || !form.password) { setErr('Username and password are required'); return; }
setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(form) })
.then(user => { onCreated(user); onClose(); })
.catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); });
};
const onKey = e => { if (e.key === 'Enter') submit(); };
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Invite user</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Username</label>
<input className="field-input" value={form.username} autoFocus
onChange={e => setForm(p => ({...p, username: e.target.value}))}
onKeyDown={onKey} placeholder="jsmith" />
</div>
<div className="field">
<label className="field-label">Display name</label>
<input className="field-input" value={form.display_name}
onChange={e => setForm(p => ({...p, display_name: e.target.value}))}
onKeyDown={onKey} placeholder="John Smith" />
</div>
<div className="field">
<label className="field-label">Password</label>
<input className="field-input" type="password" value={form.password}
onChange={e => setForm(p => ({...p, password: e.target.value}))}
onKeyDown={onKey} placeholder="Temporary password" />
</div>
<div className="field">
<label className="field-label">Role</label>
<select className="field-input" value={form.role}
onChange={e => setForm(p => ({...p, role: e.target.value}))}
style={{ appearance: 'auto' }}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create user'}</button>
</div>
</div>
</div>
);
}
function Users() {
const { USERS } = window.ZAMPP_DATA;
const [users, setUsers] = React.useState(window.ZAMPP_DATA.USERS || []);
const [tab, setTab] = React.useState("users");
const [showInvite, setShowInvite] = React.useState(false);
const exportCsv = () => {
const rows = [['Username', 'Name', 'Role', 'Last Seen']].concat(
users.map(u => [u.username || '', u.name || '', u.role || '', u.lastSeen || ''])
);
const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n');
const a = document.createElement('a');
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv);
a.download = 'users.csv';
a.click();
};
const onCreated = (user) => {
const updated = [...users, user];
setUsers(updated);
window.ZAMPP_DATA.USERS = updated;
};
return (
<div className="page">
<div className="page-header">
<h1>Users &amp; Groups</h1>
<div className="spacer" />
<button className="btn ghost sm"><Icon name="download" />Export</button>
<button className="btn primary"><Icon name="plus" />Invite user</button>
<button className="btn ghost sm" onClick={exportCsv}><Icon name="download" />Export</button>
<button className="btn primary" onClick={() => setShowInvite(true)}><Icon name="plus" />Invite user</button>
</div>
<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 === "users" ? "active" : ""} onClick={() => setTab("users")}>Users · {users.length}</button>
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups</button>
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies</button>
</div>
@ -42,10 +123,10 @@ function Users() {
<div>Last active</div>
<div></div>
</div>
{USERS.length === 0 && (
{users.length === 0 && (
<div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
)}
{USERS.map(u => (
{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.initials || u.id) }}>{u.initials || '??'}</div>
@ -64,6 +145,7 @@ function Users() {
))}
</div>
</div>
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
</div>
);
}
@ -327,6 +409,17 @@ function Containers() {
const running = (containers || []).filter(c => c.state === 'running').length;
const showLogs = (c) => {
alert('To view logs for ' + c.name + ', run:\n\ndocker compose logs -f ' + c.name);
};
const restartContainer = (c) => {
if (!window.confirm('Restart container ' + c.name + '?')) return;
window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' })
.then(() => { alert(c.name + ' restarted.'); load(); })
.catch(() => alert('No restart endpoint available.\nRun manually:\n\ndocker compose restart ' + c.name));
};
return (
<div className="page">
<div className="page-header">
@ -385,8 +478,8 @@ function Containers() {
<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>
<button className="btn ghost sm" onClick={() => showLogs(c)}>Logs</button>
<button className="btn ghost sm" onClick={() => restartContainer(c)}>Restart</button>
</div>
</div>
))}
@ -398,8 +491,19 @@ function Containers() {
}
function Cluster() {
const rawNodes = window.ZAMPP_DATA.NODES;
const nodesArr = Array.isArray(rawNodes) ? rawNodes : (rawNodes?.nodes || []);
const [nodesData, setNodesData] = React.useState(window.ZAMPP_DATA.NODES);
const [hovered, setHovered] = React.useState(null);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/cluster')
.then(data => {
window.ZAMPP_DATA.NODES = data;
setNodesData(data);
})
.catch(() => {});
}, []);
const nodesArr = Array.isArray(nodesData) ? nodesData : (nodesData?.nodes || []);
const NODES = React.useMemo(() => {
if (!nodesArr.length) return [];
@ -413,10 +517,10 @@ function Cluster() {
return _normalizeNode(n, 0.5 + 0.32 * Math.cos(angle), 0.46 + 0.35 * Math.sin(angle));
});
return [primary, ...positioned];
}, []);
}, [nodesData]);
const [hovered, setHovered] = React.useState(null);
const [selected, setSelected] = React.useState(NODES[0] || null);
const [selected, setSelected] = React.useState(null);
const sel = selected || NODES[0] || null;
const W = 720, H = 460;
if (!NODES.length) {
@ -425,7 +529,7 @@ function Cluster() {
<div className="page-header">
<h1>Cluster</h1>
<div className="spacer" />
<button className="btn ghost sm"><Icon name="refresh" />Refresh</button>
<button className="btn ghost sm" onClick={refresh}><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>
@ -440,7 +544,25 @@ function Cluster() {
to: n,
alive: n.status === 'online',
}));
const sel = selected || NODES[0];
const addNode = () => {
alert('To add a worker node:\n\n1. Install Docker + docker-compose on the target machine\n2. Copy /opt/wild-dragon to that machine\n3. Set NODE_ROLE=worker in the .env file\n4. Run: docker compose up -d\n\nThe node will register with this cluster automatically.');
};
const drainNode = (node) => {
alert('Drain is not yet automated.\n\nTo drain ' + node.id + ':\n1. Stop new jobs from routing to this node\n2. Wait for in-progress jobs to complete\n3. Then remove the node safely');
};
const removeNode = (node) => {
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine — it only removes it from cluster membership.')) return;
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.id), { method: 'DELETE' })
.then(() => refresh())
.catch(e => alert('Remove failed: ' + e.message));
};
const nodeLogsHint = (node) => {
alert('To view logs for ' + node.id + ' (' + node.ip + '):\n\nSSH to ' + node.ip + ' and run:\ndocker compose -f /opt/wild-dragon/docker-compose.yml logs -f');
};
return (
<div className="page">
@ -449,8 +571,8 @@ function Cluster() {
<span className="subtitle">{NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online</span>
<div className="spacer" />
<div className="status-pip"><span className="dot" /><span>Live</span></div>
<button className="btn ghost sm"><Icon name="refresh" />Refresh</button>
<button className="btn primary"><Icon name="plus" />Add node</button>
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={addNode}><Icon name="plus" />Add node</button>
</div>
<div className="page-body">
<div className="stat-row" style={{ padding: 0, marginBottom: 16 }}>
@ -591,9 +713,9 @@ function Cluster() {
</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>}
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
{sel.role !== "primary" && <button className="btn danger sm" onClick={() => removeNode(sel)}>Remove</button>}
</div>
</div>
</div>