diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 58904ac..e2e8c79 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -697,15 +697,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 [restartFlash, setRestartFlash] = React.useState(null); + const [logsModal, setLogsModal] = React.useState(null); + + const showLogs = (c) => setLogsModal(c); const restartContainer = (c) => { - if (!window.confirm('Restart container ' + c.name + '?')) return; + if (!window.confirm('Restart container "' + c.name + '"?\nIn-flight requests will be dropped.')) return; + setRestartFlash({ name: c.name, status: 'pending' }); 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)); + .then(() => { setRestartFlash({ name: c.name, status: 'ok' }); load(); setTimeout(() => setRestartFlash(null), 3000); }) + .catch(e => { setRestartFlash({ name: c.name, status: 'fail', error: e.message }); setTimeout(() => setRestartFlash(null), 5000); }); }; return ( @@ -722,6 +724,51 @@ function Containers() { )} + {restartFlash && ( +
+ {restartFlash.status === 'pending' && `Restarting ${restartFlash.name}…`} + {restartFlash.status === 'ok' && `${restartFlash.name} restarted.`} + {restartFlash.status === 'fail' && `${restartFlash.name}: ${restartFlash.error}`} +
+ )} + {logsModal && ( +
setLogsModal(null)}> +
e.stopPropagation()}> +
+
Logs · {logsModal.name}
+ +
+
+
+ Live log streaming over the websocket isn't wired yet. SSH to the host that runs this container and tail the logs directly: +
+ + docker compose logs -f {logsModal.name} + +
+ Or grab the last 200 lines:  + docker logs --tail 200 {logsModal.name} +
+
+
+ + +
+
+
+ )}
{containers === null && (
Loading…
@@ -833,24 +880,44 @@ function Cluster() { alive: n.status === 'online', })); - 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 [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?} - 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 addNode = () => setAdviceModal({ + title: 'Add a worker node', + lines: [ + 'Worker nodes auto-register with the cluster on first heartbeat.', + 'Run these on the new host (replace 10.0.0.25 with this MAM\'s IP):', + ], + commands: [ + 'git clone https://forge.wilddragon.net/zgaetano/dragonflight.git /opt/dragonflight', + 'cd /opt/dragonflight && cp .env.example .env && nano .env # set NODE_ROLE=worker, point at MAM IP', + 'docker compose -f docker-compose.worker.yml up -d', + ], + }); + + const drainNode = (node) => setAdviceModal({ + title: `Drain ${node.id}`, + lines: [ + 'Automated drain isn\'t implemented yet. The safe sequence is:', + '1. Stop scheduling new jobs to this node (kill its node-agent).', + '2. Let in-progress jobs finish.', + '3. Remove the node from cluster membership.', + ], + commands: [`ssh ${node.ip || node.id} 'docker stop node-agent'`], + }); 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)); + .catch(e => setAdviceModal({ title: 'Remove failed', lines: [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'); - }; + const nodeLogsHint = (node) => setAdviceModal({ + title: `Logs for ${node.id}`, + lines: ['Live log streaming over the websocket isn\'t wired yet. SSH to the host and tail there:'], + commands: [`ssh ${node.ip || node.id} 'docker compose -f /opt/dragonflight/docker-compose.yml logs -f'`], + }); return (
@@ -886,10 +953,7 @@ function Cluster() {
Topology -
- - -
+ {NODES.length} node{NODES.length === 1 ? '' : 's'}
@@ -1010,6 +1074,32 @@ function Cluster() { )}
+ {adviceModal && ( +
setAdviceModal(null)}> +
e.stopPropagation()}> +
+
{adviceModal.title}
+ +
+
+ {(adviceModal.lines || []).map((l, i) => ( +
{l}
+ ))} + {(adviceModal.commands || []).map((c, i) => ( + {c} + ))} +
+
+ {adviceModal.commands && adviceModal.commands.length > 0 && ( + + )} + +
+
+
+ )}
); } diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index 0146b36..3d4950a 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -94,6 +94,50 @@ function AssetDetail({ asset, onClose }) { if (videoRef.current) videoRef.current.currentTime = clamped / 1000; }; + // Pull a presigned hi-res URL and trigger a browser download with the + // asset's display name as the filename. Falls back to opening in a new tab. + const [downloading, setDownloading] = React.useState(false); + const downloadHires = function() { + if (downloading) return; + setDownloading(true); + window.ZAMPP_API.fetch('/assets/' + assetId + '/hires') + .then(function(r) { + if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; } + const a = document.createElement('a'); + a.href = r.url; + a.download = r.filename || (asset.name + '.' + (r.ext || 'mov')); + a.target = '_blank'; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }) + .catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); }) + .finally(function() { setDownloading(false); }); + }; + + // Right-click style menu on the kebab icon — delete, copy ID. + const [menuOpen, setMenuOpen] = React.useState(false); + const moreBtnRef = React.useRef(null); + React.useEffect(function() { + if (!menuOpen) return; + const close = function() { setMenuOpen(false); }; + window.addEventListener('click', close); + return function() { window.removeEventListener('click', close); }; + }, [menuOpen]); + + const copyId = function() { + if (navigator.clipboard) navigator.clipboard.writeText(assetId).catch(function() {}); + setMenuOpen(false); + }; + const deleteAsset = function() { + setMenuOpen(false); + if (!window.confirm('Delete "' + (asset.name || assetId) + '" permanently?\nRemoves DB row + S3 objects. Cannot be undone.')) return; + window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' }) + .then(function() { onClose && onClose(); }) + .catch(function(e) { window.alert('Delete failed: ' + e.message); }); + }; + const retryProcessing = function() { if (retrying) return; setRetrying(true); @@ -143,9 +187,20 @@ function AssetDetail({ asset, onClose }) {
- - - + +
+ + {menuOpen && ( +
+ + +
+ )} +
@@ -231,9 +286,23 @@ function AssetDetail({ asset, onClose }) { {msToTimecode(currentMs)} {asset.duration} -
- - + {videoRef.current && ( + +
+ + + + )}
)} diff --git a/services/web-ui/public/screens-editor.jsx b/services/web-ui/public/screens-editor.jsx index c82506a..caa0a0f 100644 --- a/services/web-ui/public/screens-editor.jsx +++ b/services/web-ui/public/screens-editor.jsx @@ -24,16 +24,17 @@ function Editor() {
New sequence + PREVIEW
- - + +
- +
); @@ -299,6 +307,25 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) { } function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) { + // Light cluster ping — the badge in the topbar should reflect reality, + // not just look reassuring. /metrics/home returns cluster online/total. + const [clusterHealthy, setClusterHealthy] = React.useState(true); + React.useEffect(() => { + let cancelled = false; + const ping = () => { + window.ZAMPP_API.fetch('/metrics/home?hours=1') + .then(d => { + if (cancelled) return; + const c = d?.cards?.cluster; + setClusterHealthy(!c || c.online >= c.total); + }) + .catch(() => {}); + }; + ping(); + const t = setInterval(ping, 30_000); + return () => { cancelled = true; clearInterval(t); }; + }, []); + return (
@@ -317,13 +344,10 @@ function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
-
- - cluster healthy +
+ + {clusterHealthy ? 'cluster healthy' : 'cluster degraded'}
- {right}
);