feat(admin): cluster-wide Logs page + fix container log demux + poll containers

- mam-api: dockerLogs() + demuxDockerStream() — the local container-log path
  JSON.parsed Docker's raw multiplexed stream and always returned '(no logs)';
  now strips stdcopy framing and returns readable text (tail configurable).
- web-ui: new Logs admin page — every container across every node grouped by
  node in a left rail, live-follow log viewer with filter + copy on the right.
  Reuses the now-working /cluster/containers/:node/:id/logs endpoint.
- web-ui: Containers screen now polls every 5s (was load-once) so the
  cross-cluster view stays live without manual refresh.
- icons: add server + file glyphs (were referenced but missing -> blank).
- nav: Logs wired into the Admin sidebar section + routes + breadcrumbs.
This commit is contained in:
Zac Gaetano 2026-06-04 05:28:17 +00:00
parent 1348db8f33
commit 179a740453
6 changed files with 265 additions and 8 deletions

View file

@ -72,6 +72,54 @@ function dockerRequest(path, method = 'GET', body = null) {
});
}
// Fetch a container's logs via the Docker socket and return PLAIN TEXT. The
// Docker /logs endpoint returns a multiplexed stream (8-byte stdcopy headers
// prefix each chunk for non-TTY containers), NOT JSON — so dockerRequest()'s
// JSON.parse always yielded null ('(no logs)'). Here we collect the raw bytes
// and strip the stdcopy framing so the UI gets readable log lines.
function dockerLogs(containerId, tail = 200) {
return new Promise((resolve, reject) => {
const opts = {
socketPath: '/var/run/docker.sock',
path: `/v1.41/containers/${encodeURIComponent(containerId)}/logs?stdout=1&stderr=1&tail=${tail}&timestamps=1`,
method: 'GET',
};
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', d => chunks.push(d));
res.on('end', () => {
try {
const buf = Buffer.concat(chunks);
resolve(demuxDockerStream(buf));
} catch (e) { resolve(''); }
});
});
req.on('error', reject);
req.setTimeout(6000, () => { req.destroy(); reject(new Error('Docker socket timeout')); });
req.end();
});
}
// Strip Docker's stdcopy multiplexing headers (8 bytes per frame: [stream type,
// 0,0,0, big-endian uint32 length]). TTY containers send raw text with no
// framing; detect that and pass through. Returns a UTF-8 string.
function demuxDockerStream(buf) {
if (!buf || buf.length === 0) return '';
// Heuristic: a valid stdcopy frame has byte0 in {0,1,2} and bytes 1-3 == 0.
const looksFramed = buf.length >= 8 && buf[0] <= 2 && buf[1] === 0 && buf[2] === 0 && buf[3] === 0;
if (!looksFramed) return buf.toString('utf8');
const out = [];
let off = 0;
while (off + 8 <= buf.length) {
const len = buf.readUInt32BE(off + 4);
off += 8;
if (len <= 0) continue;
out.push(buf.toString('utf8', off, Math.min(off + len, buf.length)));
off += len;
}
return out.join('');
}
router.get('/', async (req, res, next) => {
try {
const r = await pool.query(
@ -161,7 +209,8 @@ router.get('/containers/:nodeId/:containerId/logs', requireAdmin, async (req, re
const isLocal = node.hostname === localHostname || !node.api_url;
if (isLocal) {
const logs = await dockerRequest(`/containers/${containerId}/logs?stdout=1&stderr=1&tail=200&timestamps=1`);
const tail = Math.min(parseInt(req.query.tail, 10) || 200, 2000);
const logs = await dockerLogs(containerId, tail);
res.json({ logs: logs || '(no logs)' });
} else {
const resp = await fetch(`${node.api_url}/sidecar/${containerId}/logs`, {

View file

@ -81,7 +81,7 @@ function App() {
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
containers: ['Admin', 'Containers'], logs: ['Admin', 'Logs'], cluster: ['Admin', 'Cluster'],
settings: ['Admin', 'Settings'],
};
return (labels[route] || ['Home']).map(label => ({ label }));
@ -112,7 +112,7 @@ function App() {
// Admin-only destinations. Non-admins who reach one (deep link, keyboard
// router, stale tab) get bounced home instead of a broken/forbidden page.
// The API enforces the same rules this is just UX.
const ADMIN_ROUTES = new Set(['users', 'containers', 'cluster', 'settings']);
const ADMIN_ROUTES = new Set(['users', 'containers', 'logs', 'cluster', 'settings']);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const effectiveRoute = (ADMIN_ROUTES.has(route) && !isAdmin) ? 'home' : route;
@ -137,6 +137,7 @@ function App() {
case 'tokens': content = <Tokens />; break;
case 'billing': content = <TokensParody />; break;
case 'containers':content = <Containers />; break;
case 'logs': content = <Logs />; break;
case 'cluster': content = <Cluster />; break;
case 'settings': content = <Settings />; break;
default: content = <Home navigate={navigate} />;

View file

@ -14,6 +14,8 @@ const ICONS = {
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
dollar: <><line x1="12" y1="2" x2="12" y2="22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
container: <><rect x="3" y="6" width="18" height="12" rx="1" /><path d="M3 10h18" /><circle cx="7" cy="14" r="1" fill="currentColor" /><circle cx="11" cy="14" r="1" fill="currentColor" /></>,
server: <><rect x="3" y="4" width="18" height="7" rx="1.5" /><rect x="3" y="13" width="18" height="7" rx="1.5" /><circle cx="7" cy="7.5" r="1" fill="currentColor" /><circle cx="7" cy="16.5" r="1" fill="currentColor" /></>,
file: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /><path d="M14 3v6h6" /></>,
cluster: <><circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="19" r="2.5" /><circle cx="19" cy="19" r="2.5" /><path d="M12 7.5l-6.5 9M12 7.5l6.5 9M7.5 19h9" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></>,

View file

@ -1041,14 +1041,22 @@ function Containers() {
flashTimerRef.current = setTimeout(() => setRestartFlashSafe(null), ms);
};
function load() {
setContainers(null);
// load(showSpinner): on first load / manual refresh we blank the list to show
// the spinner; the background poll passes false so the table doesn't flicker.
function load(showSpinner = true) {
if (showSpinner) setContainers(null);
window.ZAMPP_API.fetch('/cluster/containers')
.then(data => setContainers(Array.isArray(data) ? data : (data.containers || [])))
.catch(() => setContainers([]));
.catch(() => setContainers(c => (c == null ? [] : c)));
}
React.useEffect(() => { load(); }, []);
React.useEffect(() => {
load();
// Poll every 5s so the cross-cluster view stays live (containers start/stop,
// nodes come and go) without the operator hitting Refresh.
const id = setInterval(() => load(false), 5000);
return () => clearInterval(id);
}, []);
const running = (containers || []).filter(c => c.state === 'running').length;
@ -1188,7 +1196,145 @@ function Containers() {
</div>
))}
</div>
)}
)}
</div>
</div>
);
}
//
// Logs cluster-wide log viewer. Left: every container across every node
// (grouped by node, polled). Right: the selected container's logs, fetched from
// /cluster/containers/:nodeId/:id/logs (raw Docker stream, demuxed server-side),
// auto-refreshed while live-follow is on. One place to read any container's logs
// across the whole cluster without SSHing into a box.
//
function Logs() {
const [containers, setContainers] = React.useState(null);
const [selected, setSelected] = React.useState(null); // {id, name, node_id, node_hostname}
const [logText, setLogText] = React.useState('');
const [loadingLogs, setLoadingLogs] = React.useState(false);
const [follow, setFollow] = React.useState(true);
const [filter, setFilter] = React.useState('');
const preRef = React.useRef(null);
const loadContainers = React.useCallback((spin = false) => {
if (spin) setContainers(null);
window.ZAMPP_API.fetch('/cluster/containers')
.then(d => setContainers(Array.isArray(d) ? d : (d.containers || [])))
.catch(() => setContainers(c => (c == null ? [] : c)));
}, []);
React.useEffect(() => {
loadContainers(true);
const id = setInterval(() => loadContainers(false), 8000);
return () => clearInterval(id);
}, [loadContainers]);
const fetchLogs = React.useCallback((c) => {
if (!c) return;
setLoadingLogs(true);
window.ZAMPP_API.fetch(`/cluster/containers/${c.node_id}/${c.id}/logs?tail=500`)
.then(d => { setLogText(d.logs || '(no logs)'); })
.catch(e => setLogText('Error fetching logs: ' + (e.message || e)))
.finally(() => setLoadingLogs(false));
}, []);
// Fetch on select + poll while follow is on.
React.useEffect(() => {
if (!selected) return;
fetchLogs(selected);
if (!follow) return;
const id = setInterval(() => fetchLogs(selected), 3000);
return () => clearInterval(id);
}, [selected, follow, fetchLogs]);
// Auto-scroll to bottom on new logs when following.
React.useEffect(() => {
if (follow && preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [logText, follow]);
// Group containers by node for the left rail.
const groups = React.useMemo(() => {
const m = new Map();
for (const c of (containers || [])) {
const k = c.node_hostname || 'unknown';
if (!m.has(k)) m.set(k, []);
m.get(k).push(c);
}
for (const list of m.values()) list.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [containers]);
const shownLog = React.useMemo(() => {
if (!filter.trim()) return logText;
const f = filter.toLowerCase();
return logText.split('\n').filter(l => l.toLowerCase().includes(f)).join('\n');
}, [logText, filter]);
return (
<div className="page">
<div className="page-header">
<h1>Logs</h1>
<span className="subtitle">Container logs across the whole cluster</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={() => loadContainers(true)}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div className="logs-layout">
{/* Left rail: container picker, grouped by node */}
<div className="logs-rail panel">
{containers === null && <div className="logs-rail-empty">Loading</div>}
{containers !== null && containers.length === 0 && <div className="logs-rail-empty">No containers</div>}
{groups.map(([node, list]) => (
<div key={node} className="logs-rail-group">
<div className="logs-rail-node"><Icon name="server" size={11} />{node}</div>
{list.map(c => (
<button key={c.id || c.name}
className={'logs-rail-item' + (selected && selected.id === c.id ? ' active' : '')}
onClick={() => setSelected(c)}>
<span className={'logs-rail-dot ' + (c.state === 'running' ? 'on' : 'off')} />
<span className="logs-rail-name">{c.name}</span>
</button>
))}
</div>
))}
</div>
{/* Right pane: log viewer */}
<div className="logs-view panel">
{!selected ? (
<div className="logs-view-empty">
<Icon name="file" size={26} />
<div>Select a container to view its logs</div>
</div>
) : (
<>
<div className="logs-view-head">
<div className="logs-view-title">
<span className="logs-view-name">{selected.name}</span>
<span className="logs-view-node mono">{selected.node_hostname}</span>
</div>
<div className="spacer" />
<input className="field-input logs-filter" placeholder="Filter lines…"
value={filter} onChange={e => setFilter(e.target.value)} />
<label className="logs-follow" title="Auto-refresh + scroll">
<input type="checkbox" checked={follow} onChange={e => setFollow(e.target.checked)} />
Follow
</label>
<button className="btn ghost sm" onClick={() => fetchLogs(selected)} disabled={loadingLogs}>
<Icon name="refresh" size={12} />{loadingLogs ? '…' : ''}
</button>
<button className="icon-btn" title="Copy logs" aria-label="Copy logs"
onClick={() => { if (navigator.clipboard) navigator.clipboard.writeText(logText).catch(() => {}); }}>
<Icon name="copy" size={13} />
</button>
</div>
<pre ref={preRef} className="logs-view-pre mono">{shownLog || (loadingLogs ? 'Loading…' : '(no logs)')}</pre>
</>
)}
</div>
</div>
</div>
</div>
);

View file

@ -38,6 +38,7 @@ const NAV_SECTIONS = [
{ id: "tokens", label: "Tokens", icon: "token" },
{ id: "billing", label: "Billing", icon: "dollar" },
{ id: "containers", label: "Containers", icon: "container" },
{ id: "logs", label: "Logs", icon: "file" },
{ id: "cluster", label: "Cluster", icon: "cluster" },
{ id: "settings", label: "Settings", icon: "settings" },
],

View file

@ -1437,3 +1437,61 @@
.ctx-menu button.danger:hover:not(:disabled) {
background: var(--danger-soft);
}
/* ── Logs page — cluster-wide log viewer ──────────────────────────────────── */
.logs-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 14px;
height: calc(100vh - 160px);
min-height: 420px;
}
.logs-rail {
overflow-y: auto;
padding: 8px;
}
.logs-rail-empty { padding: 24px 12px; color: var(--text-3); font-size: 12.5px; text-align: center; }
.logs-rail-group { margin-bottom: 10px; }
.logs-rail-node {
display: flex; align-items: center; gap: 6px;
font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
color: var(--text-3); padding: 6px 8px 4px;
}
.logs-rail-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 6px 8px; border-radius: 6px; border: none; background: transparent;
color: var(--text-2); font-size: 12.5px; cursor: pointer; text-align: left;
transition: background .1s ease, color .1s ease;
}
.logs-rail-item:hover { background: var(--bg-2, rgba(255,255,255,0.03)); }
.logs-rail-item.active { background: var(--accent-soft, rgba(74,158,255,0.14)); color: var(--text-1); }
.logs-rail-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono, monospace); }
.logs-rail-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.logs-rail-dot.on { background: var(--success, #2dd4a8); }
.logs-rail-dot.off { background: var(--text-4, #555); }
.logs-view { display: flex; flex-direction: column; overflow: hidden; }
.logs-view-empty {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; color: var(--text-3); font-size: 13px;
}
.logs-view-head {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-bottom: 1px solid var(--border);
}
.logs-view-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.logs-view-name { font-size: 13.5px; font-weight: 600; }
.logs-view-node { font-size: 11px; color: var(--text-3); }
.logs-filter { width: 160px; padding: 4px 8px; font-size: 12px; }
.logs-follow { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.logs-view-pre {
flex: 1; margin: 0; overflow: auto;
padding: 12px 14px; font-size: 11.5px; line-height: 1.5;
background: var(--bg-1, #0c0e12); color: var(--text-2);
white-space: pre-wrap; word-break: break-word;
}
@media (max-width: 900px) {
.logs-layout { grid-template-columns: 1fr; height: auto; }
.logs-rail { max-height: 220px; }
.logs-view { min-height: 360px; }
}