screens-admin: wire all buttons — invite user, export CSV, cluster refresh, container logs/restart, node drain/remove
This commit is contained in:
parent
d00e1c666e
commit
451bed834f
1 changed files with 142 additions and 20 deletions
|
|
@ -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 & 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue