diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index b7a8c7b..06156bb 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+
Invite user
+ +
+
+
+ + setForm(p => ({...p, username: e.target.value}))} + onKeyDown={onKey} placeholder="jsmith" /> +
+
+ + setForm(p => ({...p, display_name: e.target.value}))} + onKeyDown={onKey} placeholder="John Smith" /> +
+
+ + setForm(p => ({...p, password: e.target.value}))} + onKeyDown={onKey} placeholder="Temporary password" /> +
+
+ + +
+ {err &&
{err}
} +
+
+ + +
+
+
+ ); +} + 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 (

Users & Groups

- - + +
- +
@@ -42,10 +123,10 @@ function Users() {
Last active
- {USERS.length === 0 && ( + {users.length === 0 && (
No users found
)} - {USERS.map(u => ( + {users.map(u => (
{u.initials || '??'}
@@ -64,6 +145,7 @@ function Users() { ))}
+ {showInvite && setShowInvite(false)} />}
); } @@ -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 (
@@ -385,8 +478,8 @@ function Containers() {
{c.mem} MB
{c.ports}
- - + +
))} @@ -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() {

Cluster

- +
No cluster nodes available
@@ -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 (
@@ -449,8 +571,8 @@ function Cluster() { {NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online
Live
- - + +
@@ -591,9 +713,9 @@ function Cluster() {
)}
- - - {sel.role !== "primary" && } + + + {sel.role !== "primary" && }