// ========== DC-055: HOST JOURNALD LOG VIEWER ========== // Streams host service logs (caddy, dashcaddy-api, docker, ssh, …) via the // journalctl bind-mount added in start.sh. Server-Sent Events for live // tailing; bounded non-streaming read for historical views. (function() { 'use strict'; // Allow-list mirrors the backend's ALLOWED_UNITS so the dropdown stays // honest when the bind-mount isn't available. The server is still the // source of truth — anything not in its allow-list returns 400. const UNIT_PRESETS = [ { unit: 'caddy', label: 'Caddy (reverse proxy)' }, { unit: 'dashcaddy-api', label: 'DashCaddy API (host systemd unit, not this container)' }, { unit: 'docker', label: 'Docker daemon' }, { unit: 'ssh', label: 'SSH server' }, { unit: 'systemd-journald', label: 'systemd-journald' }, { unit: 'tailscaled', label: 'Tailscale' }, { unit: 'networkd-dispatcher', label: 'Networkd dispatcher' }, ]; injectModal('journald-modal', `

🛰️ Host Logs (journald)

Source: journald Unit: - Stream: disconnected
Select a unit and click Load tail or Stream.
`); const modal = document.getElementById('journald-modal'); const unitSelect = document.getElementById('jd-unit-select'); const searchInput = document.getElementById('jd-search'); const tailInput = document.getElementById('jd-tail'); const refreshBtn = document.getElementById('jd-refresh'); const streamBtn = document.getElementById('jd-stream'); const clearSearch = document.getElementById('jd-clear-search'); const closeBtn = document.getElementById('jd-close'); const closeBtn2 = document.getElementById('jd-close-btn'); const content = document.getElementById('jd-content'); const lineCount = document.getElementById('jd-line-count'); const filterCount = document.getElementById('jd-filter-count'); const overflowHint = document.getElementById('jd-overflow'); const unitDisplay = document.getElementById('jd-unit-display'); const streamState = document.getElementById('jd-stream-state'); let available = false; // /var/log/journal mounted? let lines = []; // current buffer (array of {timestamp, unit, text}) let streaming = false; let eventSource = null; let searchTimer = null; function escapeHtml(s) { // Local re-declaration so we don't depend on a global; same semantics // as the helper used by container-logs.js and error-logs.js. const div = document.createElement('div'); div.textContent = String(s); return div.innerHTML; } function setAvailable(isAvailable) { available = isAvailable; unitSelect.innerHTML = ''; UNIT_PRESETS.forEach(p => { const opt = document.createElement('option'); opt.value = p.unit; opt.textContent = p.label + ' (' + p.unit + ')'; unitSelect.appendChild(opt); }); unitSelect.disabled = !isAvailable; if (!isAvailable) { content.innerHTML = '
journald bind-mount not available in this container.
Requires /var/log/journal + /usr/bin/journalctl mounted (start.sh).
'; refreshBtn.disabled = true; streamBtn.disabled = true; } else { refreshBtn.disabled = false; streamBtn.disabled = false; } } async function probeAvailable() { try { const resp = await fetch('/api/v1/logs/journal/units'); if (!resp.ok) { setAvailable(false); return; } const data = await resp.json(); setAvailable(!!data.available); } catch (e) { setAvailable(false); } } function renderLines() { const term = (searchInput.value || '').trim().toLowerCase(); const filtered = term ? lines.filter(l => (l.textContent || '').toLowerCase().includes(term)) : lines; if (filtered.length === 0) { content.innerHTML = '
No entries' + (term ? ` matching "${escapeHtml(term)}"` : '') + '
'; } else { const html = filtered.map(line => { const ts = line.timestamp ? escapeHtml(line.timestamp) : '—'; const t = escapeHtml(line.textContent); return `
${ts}${t}
`; }).join(''); content.innerHTML = html; // Auto-scroll only if user is already at the bottom (don't fight them). const nearBottom = content.scrollHeight - content.scrollTop - content.clientHeight < 80; if (nearBottom) content.scrollTop = content.scrollHeight; } lineCount.textContent = `${lines.length} entries`; filterCount.textContent = term ? `${filtered.length} of ${lines.length} shown` : `${lines.length} shown`; } async function loadTail() { if (!available) return; stopStream(); const unit = unitSelect.value; if (!unit) return; const tail = Math.max(1, Math.min(5000, Number(tailInput.value) || 200)); const term = (searchInput.value || '').trim(); content.innerHTML = '
Loading…
'; try { const url = new URL('/api/v1/logs/journal', window.location.origin); url.searchParams.set('unit', unit); url.searchParams.set('tail', String(tail)); if (term) url.searchParams.set('search', term); const resp = await fetch(url.toString()); const data = await resp.json(); if (!resp.ok || !data.success) { content.innerHTML = '
Failed: ' + escapeHtml((data && data.error) || ('HTTP ' + resp.status)) + '
'; return; } unitDisplay.textContent = unit; lines = (data.entries || []).map(e => ({ timestamp: e.timestamp, unit: e.unit, textContent: e.text || '', })); overflowHint.style.display = 'none'; renderLines(); } catch (e) { content.innerHTML = '
Error: ' + escapeHtml(e.message) + '
'; } } function startStream() { if (!available) return; stopStream(); const unit = unitSelect.value; if (!unit) return; const term = (searchInput.value || '').trim(); unitDisplay.textContent = unit; streamBtn.textContent = '⏸ Stop'; streamBtn.classList.add('streaming'); streamState.textContent = 'streaming'; streamState.style.color = 'var(--ok-fg, #4ade80)'; lines = []; renderLines(); overflowHint.style.display = 'none'; const url = new URL('/api/v1/logs/journal/stream', window.location.origin); url.searchParams.set('unit', unit); if (term) url.searchParams.set('search', term); eventSource = new EventSource(url.toString()); eventSource.onmessage = (ev) => { try { const entry = JSON.parse(ev.data); if (entry.error) { // Overflow / validation / bind-mount errors if (/stream (exceeded|line cap)/.test(entry.error)) { overflowHint.style.display = ''; stopStream(); } content.innerHTML += '
⚠ ' + escapeHtml(entry.error) + '
'; content.scrollTop = content.scrollHeight; return; } lines.push({ timestamp: entry.timestamp, unit: entry.unit || unit, textContent: entry.text || '', }); // Hard cap to keep memory bounded if operator streams forever. if (lines.length > 5000) { lines = lines.slice(lines.length - 5000); overflowHint.style.display = ''; } renderLines(); } catch (_) { // Ignore malformed events; the server is authoritative. } }; eventSource.onerror = () => { // EventSource auto-reconnects; mark transient if we were expecting // more, otherwise we closed it deliberately. if (!streaming) return; }; streaming = true; } function stopStream() { streaming = false; if (eventSource) { try { eventSource.close(); } catch (_) { /* ignore */ } eventSource = null; } streamBtn.textContent = '▶ Stream'; streamBtn.classList.remove('streaming'); streamState.textContent = 'disconnected'; streamState.style.color = 'var(--muted)'; } function close() { stopStream(); modal.classList.remove('show'); } // Wire events refreshBtn.addEventListener('click', loadTail); streamBtn.addEventListener('click', () => streaming ? stopStream() : startStream()); clearSearch.addEventListener('click', () => { searchInput.value = ''; renderLines(); }); searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(renderLines, 200); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { searchInput.value = ''; renderLines(); } }); closeBtn.addEventListener('click', close); closeBtn2.addEventListener('click', close); modal.addEventListener('click', (e) => { if (e.target === modal) close(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('show')) close(); }); // Reload tail automatically when the unit dropdown changes (if we have // data already — saves a click). unitSelect.addEventListener('change', () => { if (lines.length > 0) loadTail(); }); // Hook into the existing "Container Logs" modal button so operators get a // separate entry point; mirror the openContainerLogsModal pattern. function openJournaldModal() { modal.classList.add('show'); probeAvailable(); } window.openJournaldModal = openJournaldModal; document.getElementById('view-journald-logs')?.addEventListener('click', openJournaldModal); })();