polish(ui): wire dead buttons across asset detail, shell, containers, cluster

Asset detail:
- Download now fetches /assets/:id/hires presigned URL and triggers a
  named browser download instead of doing nothing
- More icon now opens a kebab menu (Copy ID, Delete permanently)
- Approve button removed (no backend); audio + fullscreen icons
  in the player controls now actually toggle mute / requestFullscreen

Shell:
- Sidebar Sign-out now POSTs /auth/logout + reloads (no-op when auth disabled, by design)
- Topbar Notifications bell removed (dead, no backend)
- Topbar search wired: typing + Enter routes to Library with the term
  pre-loaded into Library's own search box
- Cluster-healthy pip now polls /metrics/home every 30s so it reflects
  real online-vs-total instead of always showing green

Editor:
- Dead Export / Publish / Mark in / Mark out / Add to timeline / Step
  buttons are now visibly disabled with explanatory titles; a PREVIEW
  badge sits next to the sequence name so the WIP state is obvious

Containers / Cluster admin:
- Logs button opens a modal with the docker tail command + Copy button
  instead of a JS alert
- Restart now shows an inline toast (pending/ok/fail) instead of alerts
- Cluster Add Node / Drain / Logs replace alert() with a styled advice
  modal that supports multi-line commands + Copy
- Dead Cluster topology Graph/List tab toggle removed (only Graph is
  implemented anyway)
This commit is contained in:
claude 2026-05-23 04:02:51 +00:00 committed by Zachary Gaetano
parent 630dc75787
commit f186cdeacd
5 changed files with 228 additions and 43 deletions

View file

@ -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() {
)}
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
</div>
{restartFlash && (
<div style={{
position: 'fixed', bottom: 20, right: 20, zIndex: 200,
background: restartFlash.status === 'ok' ? 'var(--success-soft)'
: restartFlash.status === 'fail' ? 'var(--danger-soft)'
: 'var(--bg-2)',
color: restartFlash.status === 'ok' ? 'var(--success)'
: restartFlash.status === 'fail' ? 'var(--danger)' : 'var(--text-2)',
border: '1px solid', borderColor: restartFlash.status === 'ok' ? 'var(--success)'
: restartFlash.status === 'fail' ? 'var(--danger)' : 'var(--border)',
borderRadius: 6, padding: '10px 14px', fontSize: 12.5, maxWidth: 320,
}}>
{restartFlash.status === 'pending' && `Restarting ${restartFlash.name}`}
{restartFlash.status === 'ok' && `${restartFlash.name} restarted.`}
{restartFlash.status === 'fail' && `${restartFlash.name}: ${restartFlash.error}`}
</div>
)}
{logsModal && (
<div className="modal-backdrop" onClick={() => setLogsModal(null)}>
<div className="modal" style={{ width: 540 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Logs · {logsModal.name}</div>
<button className="icon-btn" onClick={() => setLogsModal(null)}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>
Live log streaming over the websocket isn't wired yet. SSH to the host that runs this container and tail the logs directly:
</div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11.5, overflowX: 'auto' }}>
docker compose logs -f {logsModal.name}
</code>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 10 }}>
Or grab the last 200 lines:&nbsp;
<span className="mono">docker logs --tail 200 {logsModal.name}</span>
</div>
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => {
if (navigator.clipboard) navigator.clipboard.writeText('docker compose logs -f ' + logsModal.name).catch(() => {});
}}>Copy command</button>
<button className="btn primary sm" onClick={() => setLogsModal(null)}>Close</button>
</div>
</div>
</div>
)}
<div className="page-body">
{containers === null && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading</div>
@ -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 (
<div className="page">
@ -886,10 +953,7 @@ function Cluster() {
<div className="cluster-canvas">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>Topology</span>
<div className="tab-group">
<button className="active">Graph</button>
<button>List</button>
</div>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{NODES.length} node{NODES.length === 1 ? '' : 's'}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ display: "block", width: "100%", height: "auto" }}>
<defs>
@ -1010,6 +1074,32 @@ function Cluster() {
)}
</div>
</div>
{adviceModal && (
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>{adviceModal.title}</div>
<button className="icon-btn" onClick={() => setAdviceModal(null)}><Icon name="x" /></button>
</div>
<div className="modal-body">
{(adviceModal.lines || []).map((l, i) => (
<div key={i} style={{ fontSize: 12.5, color: 'var(--text-2)', marginBottom: 6, lineHeight: 1.55 }}>{l}</div>
))}
{(adviceModal.commands || []).map((c, i) => (
<code key={i} className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11.5, marginTop: 8, overflowX: 'auto' }}>{c}</code>
))}
</div>
<div className="modal-foot">
{adviceModal.commands && adviceModal.commands.length > 0 && (
<button className="btn ghost sm" onClick={() => {
if (navigator.clipboard) navigator.clipboard.writeText(adviceModal.commands.join('\n')).catch(() => {});
}}>Copy commands</button>
)}
<button className="btn primary sm" onClick={() => setAdviceModal(null)}>Got it</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -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 }) {
</div>
</div>
<div style={{ flex: 1 }} />
<button className="btn ghost sm"><Icon name="download" />Download</button>
<button className="btn subtle sm"><Icon name="check" />Approve</button>
<button className="icon-btn"><Icon name="more" /></button>
<button className="btn ghost sm" onClick={downloadHires} disabled={downloading} title="Download the hi-res master file">
<Icon name="download" />{downloading ? 'Preparing…' : 'Download'}
</button>
<div style={{ position: 'relative' }}>
<button ref={moreBtnRef} className="icon-btn" onClick={function(e) { e.stopPropagation(); setMenuOpen(function(v) { return !v; }); }}>
<Icon name="more" />
</button>
{menuOpen && (
<div className="row-menu" style={{ right: 0, left: 'auto' }} onClick={function(e) { e.stopPropagation(); }}>
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
<button className="danger" onClick={deleteAsset}><Icon name="trash" size={11} />Delete permanently</button>
</div>
)}
</div>
</div>
<div className="asset-detail-body">
@ -231,9 +286,23 @@ function AssetDetail({ asset, onClose }) {
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span>
<PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 70, textAlign: "right" }}>{asset.duration}</span>
<div style={{ width: 1, height: 18, background: "var(--border-strong)" }} />
<button className="icon-btn"><Icon name="audio" size={14} /></button>
<button className="icon-btn"><Icon name="layout" size={14} /></button>
{videoRef.current && (
<React.Fragment>
<div style={{ width: 1, height: 18, background: "var(--border-strong)" }} />
<button
className="icon-btn"
title={videoRef.current.muted ? 'Unmute' : 'Mute'}
onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
<Icon name="audio" size={14} />
</button>
<button
className="icon-btn"
title="Toggle fullscreen"
onClick={function() { if (videoRef.current && videoRef.current.requestFullscreen) videoRef.current.requestFullscreen().catch(function() {}); }}>
<Icon name="layout" size={14} />
</button>
</React.Fragment>
)}
</div>
)}

View file

@ -24,16 +24,17 @@ function Editor() {
<div className="editor-shell" style={{ position: 'relative' }}>
<div className="editor-topbar">
<span style={{ fontWeight: 600, fontSize: 13 }}>New sequence</span>
<span className="badge dev" style={{ marginLeft: 8 }}>PREVIEW</span>
<div style={{ flex: 1 }} />
<button className="btn ghost sm"><Icon name="download" />Export</button>
<button className="btn primary sm">Publish</button>
<button className="btn ghost sm" disabled title="Export not yet implemented — use the Premiere panel for now"><Icon name="download" />Export</button>
<button className="btn primary sm" disabled title="Publish to MAM not yet implemented">Publish</button>
</div>
<div className="editor-body">
<aside className="editor-bins">
<div style={{ padding: '12px 12px 8px', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: 12 }}>Project bin</span>
<span style={{ flex: 1 }} />
<button className="icon-btn"><Icon name="search" size={12} /></button>
<button className="icon-btn" disabled title="Bin search not yet implemented"><Icon name="search" size={12} /></button>
</div>
<div style={{ padding: '0 8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{ASSETS.length === 0 ? (
@ -60,15 +61,15 @@ function Editor() {
<div className="player-tc"><span className="mono">{_fmtTimecode(currentMs)}</span></div>
</div>
<div className="editor-transport">
<button className="icon-btn" onClick={() => setCurrentMs(0)}><Icon name="arrowLeft" size={14} /></button>
<button className="icon-btn" onClick={() => setPlaying(p => !p)}>
<button className="icon-btn" onClick={() => setCurrentMs(0)} title="Go to start"><Icon name="arrowLeft" size={14} /></button>
<button className="icon-btn" onClick={() => setPlaying(p => !p)} title={playing ? 'Pause' : 'Play'}>
<Icon name={playing ? 'pause' : 'play'} size={14} />
</button>
<button className="icon-btn"><Icon name="arrowRight" size={14} /></button>
<button className="icon-btn" disabled title="Step forward — not yet implemented"><Icon name="arrowRight" size={14} /></button>
<span style={{ flex: 1 }} />
<button className="btn ghost sm">Mark in</button>
<button className="btn ghost sm">Mark out</button>
<button className="btn subtle sm">Add to timeline</button>
<button className="btn ghost sm" disabled title="In/out trim points — not yet implemented">Mark in</button>
<button className="btn ghost sm" disabled title="In/out trim points — not yet implemented">Mark out</button>
<button className="btn subtle sm" disabled title="Timeline editing — not yet implemented">Add to timeline</button>
</div>
</div>
<aside className="editor-insp">

View file

@ -4,7 +4,8 @@ function Library({ navigate, onOpenAsset, openProject }) {
const { BINS, PROJECTS } = window.ZAMPP_DATA;
const [view, setView] = React.useState('grid');
const [filter, setFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
const [search, setSearch] = React.useState(window._dfPendingSearch || '');
React.useEffect(() => { delete window._dfPendingSearch; }, []);
// Local state lets us re-render after delete / move-to-bin without forcing
// a full app reload keeps ZAMPP_DATA in sync as the cache of record.
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);

View file

@ -132,7 +132,15 @@ function Sidebar({ active, onNavigate }) {
<div className="user-name">Zach Gaetano</div>
<div className="user-role">admin · broadcast</div>
</div>
<button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>
<button className="icon-btn" data-tip="Sign out" title="Sign out"
onClick={() => {
// Best-effort logout API call may 401 with auth disabled, that's fine.
window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' })
.catch(() => {})
.finally(() => { window.location.reload(); });
}}>
<Icon name="power" />
</button>
</div>
</aside>
);
@ -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 (
<header className="topbar">
<div className="crumb">
@ -317,13 +344,10 @@ function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
</div>
<div className="spacer" />
<GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />
<div className="status-pip">
<span className="dot" />
<span>cluster healthy</span>
<div className="status-pip" title={clusterHealthy ? 'All nodes online' : 'One or more nodes offline'}>
<span className="dot" style={{ background: clusterHealthy ? 'var(--success)' : 'var(--warning)' }} />
<span>{clusterHealthy ? 'cluster healthy' : 'cluster degraded'}</span>
</div>
<button className="icon-btn" data-tip="Notifications">
<Icon name="bell" />
</button>
{right}
</header>
);