diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js
index 17f1ea2..fc34301 100644
--- a/services/mam-api/src/routes/cluster.js
+++ b/services/mam-api/src/routes/cluster.js
@@ -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}×tamps=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×tamps=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`, {
diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx
index f5b05a2..1510629 100644
--- a/services/web-ui/public/app.jsx
+++ b/services/web-ui/public/app.jsx
@@ -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 = ; break;
case 'billing': content = ; break;
case 'containers':content = ; break;
+ case 'logs': content = ; break;
case 'cluster': content = ; break;
case 'settings': content = ; break;
default: content = ;
diff --git a/services/web-ui/public/icons.jsx b/services/web-ui/public/icons.jsx
index 8526d00..0997222 100644
--- a/services/web-ui/public/icons.jsx
+++ b/services/web-ui/public/icons.jsx
@@ -14,6 +14,8 @@ const ICONS = {
token: <>>,
dollar: <>>,
container: <>>,
+ server: <>>,
+ file: <>>,
cluster: <>>,
settings: <>>,
search: <>>,
diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx
index 3026199..3f5141d 100644
--- a/services/web-ui/public/screens-admin.jsx
+++ b/services/web-ui/public/screens-admin.jsx
@@ -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() {
))}
- )}
+ )}
+
+
+ );
+ }
+
+// ────────────────────────────────────────────────────────────────────────────
+// 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 (
+
+
+
Logs
+
Container logs across the whole cluster
+
+
+
+
+
+ {/* Left rail: container picker, grouped by node */}
+
+ {containers === null &&
Loading…
}
+ {containers !== null && containers.length === 0 &&
No containers
}
+ {groups.map(([node, list]) => (
+
+
{node}
+ {list.map(c => (
+
+ ))}
+
+ ))}
+
+
+ {/* Right pane: log viewer */}
+
+ {!selected ? (
+
+
+
Select a container to view its logs
+
+ ) : (
+ <>
+
+
+ {selected.name}
+ {selected.node_hostname}
+
+
+
setFilter(e.target.value)} />
+
+
+
+
+
{shownLog || (loadingLogs ? 'Loading…' : '(no logs)')}
+ >
+ )}
+
+
);
diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx
index d7339f8..4874489 100644
--- a/services/web-ui/public/shell.jsx
+++ b/services/web-ui/public/shell.jsx
@@ -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" },
],
diff --git a/services/web-ui/public/styles-rest.css b/services/web-ui/public/styles-rest.css
index 9b4961a..f790208 100644
--- a/services/web-ui/public/styles-rest.css
+++ b/services/web-ui/public/styles-rest.css
@@ -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; }
+}