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; } +}