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:
parent
630dc75787
commit
f186cdeacd
5 changed files with 228 additions and 43 deletions
|
|
@ -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:
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 || []);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue