- New container-logs.js module for viewing Docker container logs
- Integrated with existing API endpoints (/logs/containers, /logs/container/:id, /logs/stream/:id)
- Features:
- Select container from dropdown
- View logs with stdout/stderr color coding
- Real-time log streaming via SSE
- Search/filter within logs
- Download logs as text file
- Line count and filter indicators
- Added '📜 Container Logs' button to Tools section in index.html
- Added to features.js bundle via build.js
- Rebuilt dist files
404 lines
16 KiB
JavaScript
404 lines
16 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 modal (close on backdrop click)
|
|
wireModal(modal, null, closeModal);
|
|
})();
|