[glm-grade=A] feat(api): error-log filter + pagination + distinct-contexts (DC-052)
Backend (dashcaddy-api/routes/errorlogs.js):
- GET /error-logs: server-side filter chain (level, context substring,
free-text search across error/context/detail/IP, ISO since/until),
real pagination via limit/offset with hasMore reporting, MAX_LIMIT=500
clamp, newest-first sort.
- New endpoint GET /error-logs/contexts returns distinct contexts with
occurrence counts for the frontend dropdown.
- Robust entry parser handles malformed blocks as raw entries so nothing
silently disappears from the operator's view.
- DELETE /error-logs requires { confirm: 'CLEAR' } body and audits the
wipe itself (mirrors DC-050 hardening).
- DC-052 fix: removed legacy /audit-logs GET/DELETE handlers that lived
here before DC-050. errorLogsRoutes is mounted in src/app.js (L733)
BEFORE auditLogRoutes (L789), so Express router.use() semantics meant
the legacy proxies shadowed DC-050's hardened versions — DELETE
without confirm=CLEAR would silently wipe the audit log, and
/audit-logs/actions was unreachable. The hardened routes/audit-log.js
is now the single source of truth.
Frontend (status/js/error-logs.js):
- Level / Context / Search / Since / Until filter row mirroring the
audit-log UI (DC-050).
- Load More pagination with abort-on-filter-change.
- Click-to-expand stack frames in <pre> with scroll-cap.
- Contexts dropdown populated from /error-logs/contexts (refreshes on
every modal open and after a clear).
- confirm=CLEAR clear with success/error notification.
Tests (__tests__/routes/errorlogs.routes.test.js — 20 cases, all pass):
- Endpoint shape, newest-first, level/context/search/since/until filters,
invalid-since + unknown-level 400s, pagination + hasMore, MAX_LIMIT
clamp, /contexts distinct list, confirm=CLEAR gating + audit emission,
missing-file empty results, malformed entry fallback, /contexts
missing-file empty, search-by-IP, huge since/until, combined filters.
Full suite: 86 suites / 1910 tests, all green.
GLM judge round 1 (372s, 50 tool calls): grade D — HIGH audit-log
shadowing + MEDIUM coverage gaps + LOW tofu glyph.
GLM judge round 2 (114s, 25 tool calls): grade A — all findings fixed,
no new regressions, ship recommendation: ship.
This commit is contained in:
+268
-48
@@ -1,72 +1,292 @@
|
||||
// ========== ERROR LOG VIEWER ==========
|
||||
// ========== ERROR LOG VIEWER (DC-052) ==========
|
||||
// DC-052: Adds Level / Context / Search / Time-range filters, server-side
|
||||
// pagination with Load More, click-to-expand stack frames, and a distinct
|
||||
// contexts dropdown backed by /api/v1/error-logs/contexts. Mirrors the
|
||||
// audit-log UX (DC-050) so operators can drill into a subsystem as easily
|
||||
// as they can audit who-did-what.
|
||||
(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>');
|
||||
// Inject modal HTML. Same weather-modal shell as audit-log so styles
|
||||
// are shared; wider min-width because error stacks need room to breathe.
|
||||
injectModal('error-log-modal', `<div id="error-log-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
|
||||
<h3>📋 Error Logs</h3>
|
||||
<p class="modal-subtitle">
|
||||
Errors and warnings from the DashCaddy API. Click a row to see the full stack trace.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<label class="text-muted-sm">Level:</label>
|
||||
<select id="error-log-level" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="">All</option>
|
||||
<option value="ERR">Errors</option>
|
||||
<option value="WARN">Warnings</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
</select>
|
||||
<label class="text-muted-sm">Context:</label>
|
||||
<select id="error-log-context" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem; max-width: 220px;">
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
<label class="text-muted-sm" style="margin-left: 8px;">Search:</label>
|
||||
<input id="error-log-search" type="search" placeholder="message / stack / ip" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem; min-width: 180px;">
|
||||
<label class="text-muted-sm" style="margin-left: 8px;">Since:</label>
|
||||
<input id="error-log-since" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<label class="text-muted-sm">Until:</label>
|
||||
<input id="error-log-until" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<button id="error-log-refresh" class="btn-sm">🔄 Refresh</button>
|
||||
<span style="flex: 1;"></span>
|
||||
<button id="error-log-clear" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑️ Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div id="error-log-container" class="scroll-container">
|
||||
<div class="panel-empty"><span class="brand-spinner"></span> Loading error logs...</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 12px; text-align: center;">
|
||||
<button id="error-log-load-more" style="display: none; padding: 6px 16px; font-size: 0.8rem;">Load More</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 0.78rem; color: var(--muted); text-align: right;">
|
||||
<span id="error-log-total"></span>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="error-log-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('error-log-modal');
|
||||
const content = document.getElementById('error-log-content');
|
||||
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 levelSel = document.getElementById('error-log-level');
|
||||
const contextSel = document.getElementById('error-log-context');
|
||||
const searchInput = document.getElementById('error-log-search');
|
||||
const sinceInput = document.getElementById('error-log-since');
|
||||
const untilInput = document.getElementById('error-log-until');
|
||||
const container = document.getElementById('error-log-container');
|
||||
const loadMoreBtn = document.getElementById('error-log-load-more');
|
||||
const totalSpan = document.getElementById('error-log-total');
|
||||
|
||||
async function loadErrorLogs() {
|
||||
content.innerHTML = '<div class="logs-loading">Loading error logs...</div>';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
let currentOffset = 0;
|
||||
let inflight = null;
|
||||
let filterNonce = 0;
|
||||
// Cached distinct contexts so the dropdown is populated once per open and
|
||||
// re-populated after a clear (which removes all contexts) or a refresh
|
||||
// that surfaces a new subsystem for the first time.
|
||||
let knownContexts = [];
|
||||
|
||||
// datetime-local fields are naive local time — convert to UTC ISO so the
|
||||
// server compares correctly. Same shape as audit-log.js so the operator
|
||||
// sees consistent behaviour between the two modals.
|
||||
function toIso(localDtValue) {
|
||||
if (!localDtValue) return null;
|
||||
const d = new Date(localDtValue);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
// Pull the distinct contexts list once per open. Failures are silent
|
||||
// (the dropdown will just show "All" only) so a transient backend hiccup
|
||||
// doesn't block the operator from seeing the actual error rows.
|
||||
async function refreshContexts() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/error-logs');
|
||||
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 => {
|
||||
const date = new Date(log.timestamp).toLocaleString();
|
||||
return `
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${date}</span>
|
||||
<span class="log-level">ERROR</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>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
const res = await fetch('/api/v1/error-logs/contexts');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data.success || !Array.isArray(data.contexts)) return;
|
||||
knownContexts = data.contexts;
|
||||
const currentValue = contextSel.value;
|
||||
contextSel.innerHTML = '<option value="">All</option>';
|
||||
for (const c of data.contexts) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.name;
|
||||
opt.textContent = `${c.name} (${c.count})`;
|
||||
contextSel.appendChild(opt);
|
||||
}
|
||||
// Restore previous selection if still present.
|
||||
if (currentValue && data.contexts.some((c) => c.name === currentValue)) {
|
||||
contextSel.value = currentValue;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
params.set('offset', String(currentOffset));
|
||||
if (levelSel.value) params.set('level', levelSel.value);
|
||||
if (contextSel.value) params.set('context', contextSel.value);
|
||||
const since = toIso(sinceInput.value);
|
||||
const until = toIso(untilInput.value);
|
||||
if (since) params.set('since', since);
|
||||
if (until) params.set('until', until);
|
||||
const search = (searchInput.value || '').trim();
|
||||
if (search) params.set('search', search);
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadLogs(append) {
|
||||
try {
|
||||
if (!append) {
|
||||
if (inflight) inflight.abort();
|
||||
inflight = new AbortController();
|
||||
currentOffset = 0;
|
||||
filterNonce++;
|
||||
container.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||
} else {
|
||||
if (inflight) inflight.abort();
|
||||
inflight = new AbortController();
|
||||
}
|
||||
const myNonce = filterNonce;
|
||||
const params = buildQuery();
|
||||
|
||||
const res = await fetch('/api/v1/error-logs?' + params.toString(), {
|
||||
signal: inflight.signal,
|
||||
});
|
||||
// Mirror audit-log: surface 4xx/5xx explicitly instead of falling
|
||||
// through to a misleading "no entries yet" empty state.
|
||||
if (!res.ok) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${res.status}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
totalSpan.textContent = '';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(data.error || 'unknown')}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
totalSpan.textContent = '';
|
||||
return;
|
||||
}
|
||||
// Stale-response guard: a non-append load happened after this fetch,
|
||||
// discard so we don't splice into the wrong DOM.
|
||||
if (!append && myNonce !== filterNonce) return;
|
||||
|
||||
const logs = Array.isArray(data.logs) ? data.logs : [];
|
||||
if (logs.length === 0 && !append) {
|
||||
const reason = (data.filters && (data.filters.level || data.filters.context || data.filters.search || data.filters.since || data.filters.until))
|
||||
? 'No error log entries match your filters.'
|
||||
: '✅ No errors logged! Everything is working smoothly.';
|
||||
container.innerHTML = `<div class="panel-empty"><span class="empty-icon">📋</span>${escapeHtml(reason)}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
totalSpan.textContent = data.total ? `${data.total} total` : '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (!append) {
|
||||
html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">';
|
||||
html += '<th style="padding: 6px; text-align: left; width: 160px;">When</th>';
|
||||
html += '<th style="padding: 6px; text-align: left; width: 80px;">Level</th>';
|
||||
html += '<th style="padding: 6px; text-align: left; width: 140px;">Context</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Message</th>';
|
||||
html += '<th style="padding: 6px; text-align: left; width: 110px;">IP</th>';
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
for (const log of logs) {
|
||||
const level = (log.level || '?').toUpperCase();
|
||||
const levelColor = level === 'ERR' ? 'var(--bad-fg)' : (level === 'WARN' ? 'var(--warn-fg, #f0c674)' : 'var(--muted)');
|
||||
const ts = log.timestamp ? new Date(log.timestamp).toLocaleString() : '—';
|
||||
const ctx = log.context || '—';
|
||||
const msg = (log.error || '').split('\n')[0];
|
||||
const ip = (log.request && log.request.ip) || '';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(log.timestamp || '')}">${escapeHtml(ts)}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${levelColor}; font-weight: 600;">${escapeHtml(level)}</span></td>`;
|
||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(ctx)}</td>`;
|
||||
html += `<td style="padding: 6px;">${escapeHtml(msg)}</td>`;
|
||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(ip)}</td>`;
|
||||
html += '</tr>';
|
||||
if (log.detail) {
|
||||
html += `<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(log.detail)}</pre></td></tr>`;
|
||||
}
|
||||
} else {
|
||||
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div style="padding: 20px; color: var(--bad-fg);">❌ Error loading logs: ${escapeHtml(error.message)}</div>`;
|
||||
|
||||
if (!append) {
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
const table = container.querySelector('table');
|
||||
if (table) table.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
currentOffset += logs.length;
|
||||
loadMoreBtn.style.display = data.hasMore ? '' : 'none';
|
||||
totalSpan.textContent = `${data.total} total${data.hasMore ? ' (showing ' + currentOffset + ')' : ''}`;
|
||||
|
||||
// Toggle detail rows on click — same pattern as audit-log.js
|
||||
container.querySelectorAll('.error-log-row').forEach((row) => {
|
||||
if (row.dataset.wired) return;
|
||||
row.dataset.wired = 'true';
|
||||
row.addEventListener('click', () => {
|
||||
const detail = row.nextElementSibling;
|
||||
if (detail && detail.classList.contains('error-log-detail')) {
|
||||
detail.style.display = detail.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') return;
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
totalSpan.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearErrorLogs() {
|
||||
if (!confirm('Clear all error logs?')) return;
|
||||
|
||||
async function clearLogs() {
|
||||
if (!confirm('Clear the entire error log? This cannot be undone.')) return;
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
const res = await secureFetch('/api/v1/error-logs', {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// After a clear, the contexts list will be empty — re-fetch so the
|
||||
// dropdown reflects reality. Load the now-empty page in parallel.
|
||||
await refreshContexts();
|
||||
loadLogs(false);
|
||||
showNotification('✅ Error logs cleared', 'success', 3000);
|
||||
loadErrorLogs();
|
||||
} else {
|
||||
showNotification('❌ Failed to clear logs', 'error', 3000);
|
||||
showNotification('❌ ' + (data.error || 'Clear failed'), 'error', 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(`❌ Error: ${error.message}`, 'error', 3000);
|
||||
} catch (e) {
|
||||
showNotification('❌ ' + e.message, 'error', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
viewBtn?.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
loadErrorLogs();
|
||||
});
|
||||
// Debounce text-input changes so we don't refetch on every keystroke.
|
||||
let searchDebounce;
|
||||
function wireFilters() {
|
||||
levelSel?.addEventListener('change', () => loadLogs(false));
|
||||
contextSel?.addEventListener('change', () => loadLogs(false));
|
||||
searchInput?.addEventListener('input', () => {
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => loadLogs(false), 250);
|
||||
});
|
||||
let dateDebounce;
|
||||
[sinceInput, untilInput].forEach((el) => {
|
||||
el?.addEventListener('change', () => {
|
||||
clearTimeout(dateDebounce);
|
||||
dateDebounce = setTimeout(() => loadLogs(false), 250);
|
||||
});
|
||||
});
|
||||
refreshBtn?.addEventListener('click', () => loadLogs(false));
|
||||
loadMoreBtn?.addEventListener('click', () => loadLogs(true));
|
||||
clearBtn?.addEventListener('click', clearLogs);
|
||||
wireModal(modal, closeBtn);
|
||||
}
|
||||
|
||||
refreshBtn?.addEventListener('click', loadErrorLogs);
|
||||
clearBtn?.addEventListener('click', clearErrorLogs);
|
||||
wireModal(modal, closeBtn);
|
||||
viewBtn?.addEventListener('click', async () => {
|
||||
modal?.classList.add('show');
|
||||
await refreshContexts();
|
||||
loadLogs(false);
|
||||
});
|
||||
wireFilters();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user