// ========== CONTAINER LOG VIEWER ========== (function() { // Inject modal HTML injectModal('container-logs-modal', `

📜 Container Logs

Image: - Status: - Created: -
Select a container to view logs
`); const modal = document.getElementById('container-logs-modal'); const containerSelect = document.getElementById('cl-container-select'); const logContent = document.getElementById('cl-log-content'); const logSearch = document.getElementById('cl-log-search'); const logTail = document.getElementById('cl-log-tail'); const refreshBtn = document.getElementById('cl-refresh'); const streamBtn = document.getElementById('cl-stream'); const downloadBtn = document.getElementById('cl-download'); const clearSearchBtn = document.getElementById('cl-clear-search'); const closeBtn = document.getElementById('cl-close'); const closeBtn2 = document.getElementById('cl-close-btn'); const streamStatus = document.getElementById('cl-stream-status'); const streamIndicator = document.getElementById('cl-stream-indicator'); const streamText = document.getElementById('cl-stream-text'); const lineCount = document.getElementById('cl-line-count'); const filterCount = document.getElementById('cl-filter-count'); // Container info elements const imageEl = document.getElementById('cl-image'); const statusEl = document.getElementById('cl-status'); const createdEl = document.getElementById('cl-created'); let currentContainerId = null; let currentLogs = []; let filteredLogs = []; let eventSource = null; let isStreaming = false; let searchTimeout = null; // Format date function formatDate(dateStr) { if (!dateStr) return '-'; const d = new Date(dateStr); if (isNaN(d.getTime())) return dateStr; return d.toLocaleString(); } // Escape HTML function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // Format log entry for display function formatLogEntry(log, index) { const streamClass = log.stream === 'stderr' ? 'log-stderr' : 'log-stdout'; const streamIcon = log.stream === 'stderr' ? '⚠️' : '📤'; return `
${index + 1} ${streamIcon} ${escapeHtml(log.text)}
`; } // Render logs to the content area function renderLogs(logs, searchTerm = '') { if (!logs || logs.length === 0) { logContent.innerHTML = '
No logs available
'; lineCount.textContent = '0 lines'; filterCount.textContent = '0 filtered'; return; } currentLogs = logs; filteredLogs = searchTerm ? logs.filter(log => log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase()) ) : logs; lineCount.textContent = `${logs.length} lines`; filterCount.textContent = searchTerm ? `${filteredLogs.length} of ${logs.length} shown` : `${logs.length} shown`; if (filteredLogs.length === 0) { logContent.innerHTML = `
No logs match "${escapeHtml(searchTerm)}"
`; return; } logContent.innerHTML = filteredLogs.map((log, i) => formatLogEntry(log, i)).join(''); // Scroll to bottom logContent.scrollTop = logContent.scrollHeight; } // Load container list async function loadContainers() { try { const data = await getJSON('/api/v1/logs/containers'); const containers = data.containers || []; // Store current selection const currentVal = containerSelect.value; containerSelect.innerHTML = ''; containers.forEach(c => { const option = document.createElement('option'); option.value = c.id; option.textContent = `${c.name} (${c.image.split(':')[0]}) - ${c.status}`; option.dataset.name = c.name; option.dataset.image = c.image; option.dataset.status = c.status; option.dataset.created = c.created; containerSelect.appendChild(option); }); // Restore selection if still valid if (currentVal && containerSelect.querySelector(`option[value="${currentVal}"]`)) { containerSelect.value = currentVal; loadContainerInfo(currentVal); } } catch (err) { console.error('Failed to load containers:', err); } } // Load container info function loadContainerInfo(containerId) { const option = containerSelect.querySelector(`option[value="${containerId}"]`); if (option) { imageEl.textContent = option.dataset.image || '-'; statusEl.textContent = option.dataset.status || '-'; statusEl.style.color = option.dataset.status === 'running' ? 'var(--ok-fg, #4ade80)' : 'var(--bad-fg, #ef4444)'; createdEl.textContent = formatDate(option.dataset.created); } } // Load logs for selected container async function loadLogs() { const containerId = containerSelect.value; if (!containerId) { logContent.innerHTML = '
Select a container to view logs
'; return; } // Stop any existing stream stopStream(); currentContainerId = containerId; loadContainerInfo(containerId); const tail = logTail.value; const searchTerm = logSearch.value.trim(); logContent.innerHTML = '
Loading logs...
'; try { const url = `/api/v1/logs/container/${containerId}${tail !== 'all' ? `?tail=${tail}` : ''}`; const data = await getJSON(url); if (data.logs && data.logs.length > 0) { renderLogs(data.logs, searchTerm); } else { logContent.innerHTML = '
No logs found for this container
'; lineCount.textContent = '0 lines'; filterCount.textContent = '0 filtered'; } } catch (err) { logContent.innerHTML = `
Error loading logs: ${escapeHtml(err.message)}
`; } } // Start streaming logs function startStream() { const containerId = containerSelect.value; if (!containerId) return; // Stop any existing stream stopStream(); isStreaming = true; streamBtn.textContent = '⏹ Stop'; streamStatus.style.display = 'flex'; streamIndicator.textContent = '🟢'; streamText.textContent = 'Connecting...'; const url = `/api/v1/logs/stream/${containerId}`; eventSource = new EventSource(url); eventSource.onopen = () => { streamIndicator.textContent = '🟢'; streamText.textContent = 'Connected - streaming logs'; }; eventSource.onmessage = (event) => { try { const log = JSON.parse(event.data); if (log.error) { streamIndicator.textContent = '🔴'; streamText.textContent = `Error: ${log.error}`; return; } // Add to current logs currentLogs.push(log); filteredLogs.push(log); // Update counts lineCount.textContent = `${currentLogs.length} lines`; filterCount.textContent = `${filteredLogs.length} shown`; // Append new log entry const searchTerm = logSearch.value.trim(); if (!searchTerm || (log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase()))) { const entry = document.createElement('div'); entry.innerHTML = formatLogEntry(log, filteredLogs.length - 1); const entryDiv = entry.firstElementChild; entryDiv.style.background = '#1a3a1a'; logContent.appendChild(entryDiv); // Auto-scroll to bottom logContent.scrollTop = logContent.scrollHeight; } } catch (e) { console.error('Error parsing log:', e); } }; eventSource.onerror = () => { streamIndicator.textContent = '🔴'; streamText.textContent = 'Disconnected'; isStreaming = false; streamBtn.textContent = '▶ Stream'; }; // Store the EventSource for cleanup modal._eventSource = eventSource; } // Stop streaming logs function stopStream() { if (eventSource) { eventSource.close(); eventSource = null; } if (modal._eventSource) { modal._eventSource.close(); modal._eventSource = null; } isStreaming = false; streamBtn.textContent = '▶ Stream'; streamStatus.style.display = 'none'; } // Download logs as file function downloadLogs() { if (!currentLogs || currentLogs.length === 0) { showNotification('No logs to download', 'error'); return; } const containerName = containerSelect.querySelector(`option[value="${currentContainerId}"]`)?.dataset.name || currentContainerId; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${containerName}-logs-${timestamp}.txt`; const content = currentLogs.map(log => { const timestamp = log.timestamp || ''; const stream = log.stream === 'stderr' ? '[ERR]' : '[OUT]'; return `${timestamp ? timestamp + ' ' : ''}${stream} ${log.text}`; }).join('\n'); const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification(`Downloaded ${currentLogs.length} log lines`, 'success'); } // Event listeners containerSelect?.addEventListener('change', () => { loadLogs(); }); logTail?.addEventListener('change', () => { loadLogs(); }); refreshBtn?.addEventListener('click', () => { loadLogs(); }); streamBtn?.addEventListener('click', () => { if (isStreaming) { stopStream(); } else { startStream(); } }); downloadBtn?.addEventListener('click', () => { downloadLogs(); }); clearSearchBtn?.addEventListener('click', () => { logSearch.value = ''; renderLogs(currentLogs, ''); }); logSearch?.addEventListener('input', () => { // Debounce search clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { renderLogs(currentLogs, logSearch.value.trim()); }, 300); }); logSearch?.addEventListener('keydown', (e) => { if (e.key === 'Escape') { logSearch.value = ''; renderLogs(currentLogs, ''); } }); // Open modal const openBtn = document.getElementById('view-container-logs'); openBtn?.addEventListener('click', () => { modal.classList.add('show'); loadContainers(); }); // Close modal handlers function closeModal() { stopStream(); modal.classList.remove('show'); } closeBtn?.addEventListener('click', closeModal); closeBtn2?.addEventListener('click', closeModal); // Close on escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('show')) { closeModal(); } }); // Wire backdrop click to closeModal so the log stream stops too. // (Can't use wireModal here — it only does modal.classList.remove('show') // on backdrop click, which would leak the SSE/stream connection.) modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); // Expose for use by service card buttons (grid.js calls openContainerLogsModal) window.openContainerLogsModal = function(containerId, containerName) { modal.classList.add('show'); loadContainers().then(() => { // Try to find and select the container const option = Array.from(containerSelect.options).find(opt => opt.value === containerId || opt.dataset.name === containerName ); if (option) { containerSelect.value = option.value; loadContainerInfo(option.value); loadLogs(); } else if (containerId) { // If container not found in list but we have an ID, try loading directly currentContainerId = containerId; imageEl.textContent = containerName || containerId; statusEl.textContent = '-'; createdEl.textContent = '-'; loadLogs(); } else { // No container ID, just show modal with container list logContent.innerHTML = '
Select a container to view logs
'; } }); }; })();