feat(logs): error-log parser fix + dedicated /logs page (DC-051) [UNJUDGED]
This commit is contained in:
+85
-13
@@ -1,40 +1,110 @@
|
||||
// ========== ERROR LOG VIEWER ==========
|
||||
// DC-051: The /api/v1/error-logs route now parses the unified-logger
|
||||
// ── (U+2500) separator (verified 2026-08-18 — previous '=' splitter
|
||||
// returned ZERO entries and the modal always rendered "No errors logged").
|
||||
// The modal now renders the captured `details` (stack trace + req context)
|
||||
// and offers a Level filter + tail cap mirroring the audit-log viewer
|
||||
// (DC-050). Mirrors the audit-log-viewer shape (5f95fdc).
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('error-log-modal', '<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>📋 Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">🔄 Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">🗑️ Clear</button><button id="error-log-close" class="close-btn">✕</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');
|
||||
const MAX_LEVELS = ['ERR', 'WRN', 'INF', 'DBG'];
|
||||
|
||||
injectModal('error-log-modal', [
|
||||
'<div id="error-log-modal" class="logs-modal">',
|
||||
' <div class="logs-modal-content">',
|
||||
' <div class="logs-header">',
|
||||
' <h3>📋 Error Logs</h3>',
|
||||
' <div class="logs-controls">',
|
||||
' <select id="error-log-level" aria-label="Filter by level" style="padding:4px 8px!important;font-size:.85rem!important">',
|
||||
' <option value="">All levels</option>',
|
||||
' <option value="ERR">Errors</option>',
|
||||
' <option value="WRN">Warnings</option>',
|
||||
' <option value="INF">Info</option>',
|
||||
' <option value="DBG">Debug</option>',
|
||||
' </select>',
|
||||
' <select id="error-log-tail" aria-label="Tail length" style="padding:4px 8px!important;font-size:.85rem!important">',
|
||||
' <option value="50">Last 50</option>',
|
||||
' <option value="100" selected>Last 100</option>',
|
||||
' <option value="200">Last 200</option>',
|
||||
' <option value="500">Last 500</option>',
|
||||
' </select>',
|
||||
' <button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">🔄 Refresh</button>',
|
||||
' <button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">🗑️ Clear</button>',
|
||||
' <button id="error-log-close" class="close-btn">✕</button>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="logs-container">',
|
||||
' <div id="error-log-meta" class="logs-meta" style="padding:6px 12px;color:var(--muted);font-size:.8rem;border-bottom:1px solid var(--border)"></div>',
|
||||
' <div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
'</div>',
|
||||
].join(''));
|
||||
|
||||
const modal = document.getElementById('error-log-modal');
|
||||
const content = document.getElementById('error-log-content');
|
||||
const meta = document.getElementById('error-log-meta');
|
||||
const viewBtn = document.getElementById('view-error-logs');
|
||||
const refreshBtn = document.getElementById('error-log-refresh');
|
||||
const clearBtn = document.getElementById('error-log-clear');
|
||||
const closeBtn = document.getElementById('error-log-close');
|
||||
const levelSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-level'));
|
||||
const tailSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-tail'));
|
||||
|
||||
function levelClass(level) {
|
||||
const L = (level || '').toUpperCase();
|
||||
if (L === 'ERR') return 'log-entry error';
|
||||
if (L === 'WRN') return 'log-entry warn';
|
||||
if (L === 'INF') return 'log-entry info';
|
||||
if (L === 'DBG') return 'log-entry debug';
|
||||
return 'log-entry';
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MiB';
|
||||
}
|
||||
|
||||
async function loadErrorLogs() {
|
||||
content.innerHTML = '<div class="logs-loading">Loading error logs...</div>';
|
||||
|
||||
meta.textContent = '';
|
||||
|
||||
const tail = encodeURIComponent(tailSelect.value || '100');
|
||||
const level = levelSelect.value || '';
|
||||
const qs = `tail=${tail}` + (level ? `&level=${encodeURIComponent(level)}` : '');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/error-logs');
|
||||
const response = await fetch('/api/v1/error-logs?' + qs);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success && data.logs) {
|
||||
if (data.logs.length === 0) {
|
||||
content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--muted);">✅ No errors logged! Everything is working smoothly.</div>';
|
||||
} else {
|
||||
content.innerHTML = data.logs.map(log => {
|
||||
content.innerHTML = data.logs.map((log, idx) => {
|
||||
const date = new Date(log.timestamp).toLocaleString();
|
||||
const lvl = (log.level || 'ERR').toUpperCase();
|
||||
const detailsId = `error-log-details-${idx}`;
|
||||
const details = log.details ? escapeHtml(log.details) : null;
|
||||
const ctx = log.context ? `<strong>${escapeHtml(log.context)}</strong>: ` : '';
|
||||
const msg = escapeHtml(log.message || '');
|
||||
return `
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${date}</span>
|
||||
<span class="log-level">ERROR</span>
|
||||
<div class="${levelClass(log.level)}">
|
||||
<span class="log-timestamp">${escapeHtml(date)}</span>
|
||||
<span class="log-level">${escapeHtml(lvl)}</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(log.context)}</strong>: ${escapeHtml(log.error)}
|
||||
${log.details ? `<br><small style="opacity: 0.7;">${escapeHtml(log.details)}</small>` : ''}
|
||||
${ctx}${msg}
|
||||
${details ? `<br><details id="${detailsId}"><summary style="cursor:pointer;opacity:.7">stack + request context</summary><pre style="margin:6px 0 0;font-size:.75rem;background:var(--card-bg);padding:8px;border-radius:4px;overflow-x:auto">${details}</pre></details>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
const sizeStr = formatBytes(data.totalSize);
|
||||
const truncStr = data.truncated ? ' (showing last 2 MiB)' : '';
|
||||
const returnedStr = `${data.returned ?? data.logs.length}`;
|
||||
meta.textContent = `${returnedStr} entries · ${sizeStr}${truncStr}`;
|
||||
} else {
|
||||
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
||||
}
|
||||
@@ -49,7 +119,7 @@
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showNotification('✅ Error logs cleared', 'success', 3000);
|
||||
loadErrorLogs();
|
||||
@@ -68,5 +138,7 @@
|
||||
|
||||
refreshBtn?.addEventListener('click', loadErrorLogs);
|
||||
clearBtn?.addEventListener('click', clearErrorLogs);
|
||||
levelSelect?.addEventListener('change', loadErrorLogs);
|
||||
tailSelect?.addEventListener('change', loadErrorLogs);
|
||||
wireModal(modal, closeBtn);
|
||||
})();
|
||||
})();
|
||||
@@ -0,0 +1,172 @@
|
||||
// Logs page (status/logs.html) — dedicated admin log viewer.
|
||||
// Two tabs: Error log (calls /api/v1/error-logs) + Container (calls
|
||||
// /api/v1/logs/containers + /api/v1/logs/container/:id). Mirrors the
|
||||
// /api/v1/error-logs parser fix from DC-051 — U+2500 separator, capped
|
||||
// tail, level filter.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MiB';
|
||||
}
|
||||
|
||||
const out = document.getElementById('output');
|
||||
const meta = document.getElementById('meta');
|
||||
const tabError = document.getElementById('tab-error');
|
||||
const tabContainer = document.getElementById('tab-container');
|
||||
const errorCtrls = document.getElementById('error-controls');
|
||||
const containerCtrls = document.getElementById('container-controls');
|
||||
|
||||
let activeTab = 'error';
|
||||
let containers = [];
|
||||
|
||||
function switchTab(tab) {
|
||||
activeTab = tab;
|
||||
tabError.classList.toggle('active', tab === 'error');
|
||||
tabContainer.classList.toggle('active', tab === 'container');
|
||||
errorCtrls.style.display = tab === 'error' ? 'flex' : 'none';
|
||||
containerCtrls.style.display = tab === 'container' ? 'flex' : 'none';
|
||||
if (tab === 'error') loadErrorLog();
|
||||
else loadContainerList();
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || (data && data.success === false)) {
|
||||
throw new Error((data && (data.error || data.message)) || `HTTP ${r.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadErrorLog() {
|
||||
const tailEl = /** @type {HTMLSelectElement} */ (document.getElementById('tail'));
|
||||
const levelEl = /** @type {HTMLSelectElement} */ (document.getElementById('level'));
|
||||
const tail = tailEl.value;
|
||||
const level = levelEl.value;
|
||||
let qs = `tail=${encodeURIComponent(tail)}`;
|
||||
if (level) qs += '&level=' + encodeURIComponent(level);
|
||||
|
||||
out.innerHTML = '<div class="logs-loading">Loading error log…</div>';
|
||||
meta.textContent = '';
|
||||
try {
|
||||
const data = await fetchJson('/api/v1/error-logs?' + qs);
|
||||
const logs = data.logs || [];
|
||||
if (logs.length === 0) {
|
||||
out.innerHTML = '<div class="empty">✅ No errors logged</div>';
|
||||
} else {
|
||||
out.innerHTML = logs.map((log, idx) => {
|
||||
const date = new Date(log.timestamp).toLocaleString();
|
||||
const lvl = (log.level || 'ERR').toUpperCase();
|
||||
const cls = ['ERR','WRN','INF','DBG'].includes(lvl) ? lvl.toLowerCase() : 'error';
|
||||
const details = log.details ? escapeHtml(log.details) : null;
|
||||
return `
|
||||
<div class="log-entry ${cls}">
|
||||
<span class="log-timestamp">${escapeHtml(date)}</span>
|
||||
<span class="log-level">${escapeHtml(lvl)}</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(log.context || '')}</strong>: ${escapeHtml(log.message || '')}
|
||||
${details ? `<details><summary style="cursor:pointer;opacity:.7">stack + request context</summary><pre>${details}</pre></details>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
const sizeStr = formatBytes(data.totalSize);
|
||||
const truncStr = data.truncated ? ' (last 2 MiB)' : '';
|
||||
const returnedStr = data.returned ?? logs.length;
|
||||
meta.textContent = `${returnedStr} entries · ${sizeStr}${truncStr}`;
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearErrorLog() {
|
||||
if (!confirm('Clear all error logs?')) return;
|
||||
try {
|
||||
await fetchJson('/api/v1/error-logs', { method: 'DELETE' });
|
||||
meta.textContent = '✅ cleared';
|
||||
loadErrorLog();
|
||||
} catch (err) {
|
||||
meta.textContent = '❌ ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContainerList() {
|
||||
const sel = document.getElementById('container-select');
|
||||
out.innerHTML = '<div class="logs-loading">Loading containers…</div>';
|
||||
document.getElementById('container-meta').textContent = '';
|
||||
try {
|
||||
const data = await fetchJson('/api/v1/logs/containers');
|
||||
containers = data.containers || [];
|
||||
sel.innerHTML = containers.map(c => {
|
||||
const name = c.name || c.id;
|
||||
const state = (c.status || 'unknown');
|
||||
return `<option value="${escapeHtml(c.id)}">${escapeHtml(name)} (${escapeHtml(state)})</option>`;
|
||||
}).join('');
|
||||
if (containers.length === 0) {
|
||||
out.innerHTML = '<div class="empty">No containers running</div>';
|
||||
return;
|
||||
}
|
||||
loadContainerLog();
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContainerLog() {
|
||||
const sel = /** @type {HTMLSelectElement} */ (document.getElementById('container-select'));
|
||||
const tailEl = /** @type {HTMLSelectElement} */ (document.getElementById('container-tail'));
|
||||
const tail = tailEl.value;
|
||||
const id = sel.value;
|
||||
if (!id) {
|
||||
out.innerHTML = '<div class="empty">Select a container</div>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="logs-loading">Loading container logs…</div>';
|
||||
document.getElementById('container-meta').textContent = '';
|
||||
try {
|
||||
const data = await fetchJson(`/api/v1/logs/container/${encodeURIComponent(id)}?tail=${encodeURIComponent(tail)}×tamps=true`);
|
||||
const logs = data.logs || [];
|
||||
if (logs.length === 0) {
|
||||
out.innerHTML = '<div class="empty">No log lines</div>';
|
||||
} else {
|
||||
out.innerHTML = logs.map(l => {
|
||||
const cls = l.stream === 'stderr' ? 'error' : 'info';
|
||||
const ts = l.timestamp || (data.logs.length ? '' : '');
|
||||
return `
|
||||
<div class="log-entry ${cls}">
|
||||
${ts ? `<span class="log-timestamp">${escapeHtml(new Date(ts).toLocaleString())}</span>` : ''}
|
||||
<span class="log-level">${l.stream === 'stderr' ? 'ERR' : 'OUT'}</span>
|
||||
<div class="log-message"><pre style="margin:0;white-space:pre-wrap">${escapeHtml(l.text)}</pre></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
const containerName = data.containerName || '';
|
||||
document.getElementById('container-meta').textContent = `${escapeHtml(containerName)} · ${logs.length} lines`;
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
tabError.addEventListener('click', () => switchTab('error'));
|
||||
tabContainer.addEventListener('click', () => switchTab('container'));
|
||||
document.getElementById('refresh').addEventListener('click', loadErrorLog);
|
||||
document.getElementById('clear').addEventListener('click', clearErrorLog);
|
||||
document.getElementById('level').addEventListener('change', loadErrorLog);
|
||||
document.getElementById('tail').addEventListener('change', loadErrorLog);
|
||||
document.getElementById('container-refresh').addEventListener('click', loadContainerLog);
|
||||
document.getElementById('container-select').addEventListener('change', loadContainerLog);
|
||||
document.getElementById('container-tail').addEventListener('change', loadContainerLog);
|
||||
|
||||
switchTab('error');
|
||||
})();
|
||||
Reference in New Issue
Block a user