Files
dashcaddy/status/js/container-logs.js
Sami 7e4c3a0963 fix(frontend): container-logs misuse of wireModal blocked rest of features.js
container-logs.js called `wireModal(modal, null, closeModal)` — passing
the local `closeModal()` function as a third arg where wireModal expects
button elements. wireModal then did `closeModal.addEventListener('click',...)`,
threw TypeError, and because each module's IIFE is a top-level statement
in the concatenated features.js bundle, every IIFE *after* container-logs
silently skipped: snapshot, smart-arr-connect, notification-settings,
panel-tabs, backup-restore, resource-monitor, health-check, update-
management, docker-resources, compose-import, container-exec, audit-log,
weather, clock, card-badges, theme-builder, and license. Symptoms:
"Customize Theme" did nothing on click, license badge stuck at "FREE TIER"
(because license.js never ran), no weather, etc.

- container-logs.js: drop the wireModal call, wire backdrop click directly
  to the local closeModal so the SSE log stream actually stops on close.
- globals.js: harden wireModal — skip any closeBtn that isn't a real
  EventTarget. One typo upstream shouldn't take down the rest of features.js
  init silently.
2026-05-17 02:34:51 -07:00

432 lines
17 KiB
JavaScript

// ========== CONTAINER LOG VIEWER ==========
(function() {
// Inject modal HTML
injectModal('container-logs-modal', `<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
<h3 style="margin: 0;">📜 Container Logs</h3>
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">View and stream Docker container logs</p>
</div>
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center;">
<select id="cl-container-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 200px;">
<option value="">Select a container...</option>
</select>
<input type="text" id="cl-log-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 150px;" />
<select id="cl-log-tail" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
<option value="50">Last 50 lines</option>
<option value="100" selected>Last 100 lines</option>
<option value="500">Last 500 lines</option>
<option value="1000">Last 1000 lines</option>
<option value="all">All logs</option>
</select>
<button id="cl-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">🔄 Refresh</button>
<button id="cl-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">▶ Stream</button>
<button id="cl-download" style="padding: 6px 14px; font-size: 0.85rem;">💾 Download</button>
<button id="cl-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">✕</button>
<button id="cl-close" class="close-btn" style="padding: 6px 10px;">✕</button>
</div>
</div>
<div id="cl-container-info" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem;">
<span><strong>Image:</strong> <span id="cl-image">-</span></span>
<span><strong>Status:</strong> <span id="cl-status">-</span></span>
<span><strong>Created:</strong> <span id="cl-created">-</span></span>
</div>
<div id="cl-log-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>
</div>
<div id="cl-stream-status" style="display: none; padding: 8px 12px; background: var(--ok-bg, #1a3a1a); color: var(--ok-fg, #4ade80); border-radius: 6px; margin-top: 8px; font-size: 0.82rem;">
<span id="cl-stream-indicator">🔴</span> <span id="cl-stream-text">Disconnected</span>
</div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
<span id="cl-line-count">0 lines</span>
<span>|</span>
<span id="cl-filter-count">0 filtered</span>
</div>
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);
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 `
<div class="log-entry ${streamClass}" data-index="${index}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${index + 1}</span>
<span class="log-stream-icon" style="color: ${log.stream === 'stderr' ? '#f59e0b' : '#22c55e'};">${streamIcon}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${escapeHtml(log.text)}</span>
</div>
`;
}
// Render logs to the content area
function renderLogs(logs, searchTerm = '') {
if (!logs || logs.length === 0) {
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>';
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 = `<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${escapeHtml(searchTerm)}"</div>`;
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 = '<option value="">Select a container...</option>';
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 = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';
return;
}
// Stop any existing stream
stopStream();
currentContainerId = containerId;
loadContainerInfo(containerId);
const tail = logTail.value;
const searchTerm = logSearch.value.trim();
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';
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 = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>';
lineCount.textContent = '0 lines';
filterCount.textContent = '0 filtered';
}
} catch (err) {
logContent.innerHTML = `<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${escapeHtml(err.message)}</div>`;
}
}
// 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 = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';
}
});
};
})();