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'}
+ {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}
);