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:
parent
1348db8f33
commit
179a740453
6 changed files with 265 additions and 8 deletions
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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" /></>,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue