From 99bb3f6db8c0244851f8c0f5db39bc96faeb4be1 Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 17 Aug 2026 19:10:44 -0700 Subject: [PATCH] feat(logs): error-log parser fix + dedicated /logs page (DC-051) [UNJUDGED] --- .../__tests__/errorlogs-parser.test.js | 135 ++++++++++++++ dashcaddy-api/routes/errorlogs.js | 123 ++++++++++--- status/index.html | 1 + status/js/error-logs.js | 98 ++++++++-- status/js/logs-page.js | 172 ++++++++++++++++++ status/logs.html | 99 ++++++++++ 6 files changed, 593 insertions(+), 35 deletions(-) create mode 100644 dashcaddy-api/__tests__/errorlogs-parser.test.js create mode 100644 status/js/logs-page.js create mode 100644 status/logs.html diff --git a/dashcaddy-api/__tests__/errorlogs-parser.test.js b/dashcaddy-api/__tests__/errorlogs-parser.test.js new file mode 100644 index 0000000..de6a619 --- /dev/null +++ b/dashcaddy-api/__tests__/errorlogs-parser.test.js @@ -0,0 +1,135 @@ +// Unit tests for the fixed /api/v1/error-logs parser +// (route /opt/dashcaddy/dashcaddy-api/routes/errorlogs.js) +// +// Background: the prior implementation split on '='.repeat(80) but the +// unified logger writes \u2500 horizontal-rule separators. As a result +// every modal-open returned ZERO entries — same class of silent bug as +// DC-050 (audit log). These tests pin the new behavior so future refactors +// can't reintroduce it. + +const { parseEntries, readTailBytes, MAX_TAIL } = require('../routes/errorlogs'); +const path = require('path'); +const fs = require('fs'); +const fsp = require('fs').promises; +const os = require('os'); + +const SEP = '\n' + '\u2500'.repeat(72) + '\n'; + +function buildLog(entries) { + return entries.map((e, i) => { + const head = `[${e.timestamp}] [${e.level}] ${e.context}: ${e.message}`; + return head + (e.details ? '\n' + e.details : '') + SEP; + }).join(''); +} + +describe('errorlogs parser (DC-051)', () => { + test('parses single entry with U+2500 separator', () => { + const text = buildLog([{ + timestamp: '2026-08-16T23:13:14.123Z', + level: 'ERR', + context: '/api/v1/templates', + message: 'Route GET /v1/templates not found', + details: 'NotFoundError: Route GET /v1/templates not found\n at notFoundHandler (/app/src/utilities/error-handler.js:71:8)', + }]); + const out = parseEntries(text); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + timestamp: '2026-08-16T23:13:14.123Z', + level: 'ERR', + context: '/api/v1/templates', + message: 'Route GET /v1/templates not found', + }); + expect(out[0].details).toContain('notFoundHandler'); + expect(out[0].details).not.toContain('\u2500'); + }); + + test('returns multiple entries in order, ignoring separator residue', () => { + const text = buildLog([ + { timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'first' }, + { timestamp: '2026-08-16T23:01:00.000Z', level: 'WRN', context: 'b', message: 'second' }, + { timestamp: '2026-08-16T23:02:00.000Z', level: 'INF', context: 'c', message: 'third', details: 'extra' }, + ]); + const out = parseEntries(text); + expect(out.map(e => e.context)).toEqual(['a', 'b', 'c']); + expect(out[1].level).toBe('WRN'); + expect(out[2].details).toBe('extra'); + }); + + test('skips malformed lines without throwing', () => { + const text = 'this is not a log entry\n' + SEP + '[2026-08-16T23:00:00.000Z] [ERR] x: y\n' + SEP; + const out = parseEntries(text); + expect(out).toHaveLength(1); + expect(out[0].message).toBe('y'); + }); + + test('empty input returns empty array', () => { + expect(parseEntries('')).toEqual([]); + expect(parseEntries(' \n\n ')).toEqual([]); + }); + + test('regression: would have returned 0 entries under the OLD splitter', () => { + // Old impl: text.split('='.repeat(80)).filter(...). That produced one + // big block, parser rejected all headers, returned ZERO entries. New + // impl must NOT regress to that behavior on a real-format log. + const text = buildLog([ + { timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'm' }, + { timestamp: '2026-08-16T23:01:00.000Z', level: 'ERR', context: 'b', message: 'm' }, + ]); + // Sanity: the old split would produce 1 block (no '=' in the text). + expect(text.split('='.repeat(80))).toHaveLength(1); + // New parser must surface both entries. + expect(parseEntries(text)).toHaveLength(2); + }); + + test('MAX_TAIL is bounded (>=100, <=1000) — prevents unbounded read', () => { + expect(MAX_TAIL).toBeGreaterThanOrEqual(100); + expect(MAX_TAIL).toBeLessThanOrEqual(1000); + }); +}); + +describe('errorlogs readTailBytes (DC-051)', () => { + let tmpFile; + beforeAll(async () => { + tmpFile = path.join(os.tmpdir(), `dc-051-errorlog-${process.pid}.log`); + const entries = []; + for (let i = 0; i < 50; i++) { + entries.push({ + timestamp: `2026-08-16T23:${String(i % 60).padStart(2,'0')}:00.000Z`, + level: i % 2 === 0 ? 'ERR' : 'WRN', + context: `ctx-${i}`, + message: `message body ${i}`, + details: i % 3 === 0 ? `stack for ${i}` : null, + }); + } + await fsp.writeFile(tmpFile, buildLog(entries)); + }); + afterAll(async () => { + try { await fsp.unlink(tmpFile); } catch {} + }); + + test('returns parsed entries within the byte budget', async () => { + const { text, totalSize, truncated } = await readTailBytes(tmpFile, 4 * 1024); + expect(typeof totalSize).toBe('number'); + expect(typeof truncated).toBe('boolean'); + const parsed = parseEntries(text); + expect(parsed.length).toBeGreaterThan(0); + // Should never include partial first line — every parsed entry has a real timestamp. + for (const e of parsed) { + expect(e.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + } + }); + + test('truncates when file exceeds byte budget', async () => { + const stat = await fsp.stat(tmpFile); + const smallBudget = Math.floor(stat.size / 4); + const { truncated } = await readTailBytes(tmpFile, smallBudget); + expect(truncated).toBe(true); + }); + + test('does not truncate when file fits within byte budget', async () => { + const stat = await fsp.stat(tmpFile); + const bigBudget = stat.size * 2; + const { truncated } = await readTailBytes(tmpFile, bigBudget); + expect(truncated).toBe(false); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/routes/errorlogs.js b/dashcaddy-api/routes/errorlogs.js index 0478413..ae9ec39 100644 --- a/dashcaddy-api/routes/errorlogs.js +++ b/dashcaddy-api/routes/errorlogs.js @@ -1,9 +1,80 @@ const express = require('express'); -const fs = require('fs'); const fsp = require('fs').promises; const { exists } = require('../src/utilities/fs-helpers'); const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); const { success } = require('../src/utils/responses'); +const { ValidationError } = require('../src/utilities/errors'); + +// The unified error logger writes entries separated by a long horizontal-rule +// line made of U+2500 BOX DRAWINGS LIGHT HORIZONTAL (verified 2026-08-18 +// against /opt/dashcaddy/dashcaddy-api/data/error.log on DNS2 — the previous +// implementation split on '='.repeat(80), which returned ONE block and +// produced ZERO entries for the modal). Anything else got dropped silently. +const ENTRY_SEPARATOR_RE = /\n\u2500{20,}\n?/; +const ENTRY_HEADER_RE = /^\[([^\]]+)\]\s+\[([A-Z]+)\]\s+(.*?):\s*(.*)$/; + +const MAX_TAIL = 500; +const MAX_TAIL_BYTES = 2 * 1024 * 1024; // never read more than 2 MiB from disk + +/** + * Parse the unified error-log format into structured entries. + * Each entry: + * [2026-08-16T23:13:14.123Z] [ERR] ctx: message + * + * request: ... (optional) + * context: {...} (optional) + * ────────────── (separator) + * @param {string} text + * @returns {Array<{timestamp:string,level:string,context:string,message:string,details:string|null}>} + */ +function parseEntries(text) { + if (!text) return []; + const blocks = text.split(ENTRY_SEPARATOR_RE); + const entries = []; + for (const block of blocks) { + const trimmed = block.replace(/^\n+|\n+$/g, ''); + if (!trimmed) continue; + const firstLineEnd = trimmed.indexOf('\n'); + const firstLine = firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd); + const rest = firstLineEnd === -1 ? '' : trimmed.slice(firstLineEnd + 1); + const m = firstLine.match(ENTRY_HEADER_RE); + if (!m) continue; + entries.push({ + timestamp: m[1], + level: m[2], + context: m[3], + message: m[4], + details: rest ? rest.replace(/\n+$/g, '') : null, + }); + } + return entries; +} + +/** + * Read the last N bytes of a UTF-8 file safely (so the 4 MiB log doesn't + * blow up memory or block the event loop). Splits on the first complete + * line boundary after the cut. + */ +async function readTailBytes(filePath, byteLimit) { + const fh = await fsp.open(filePath, 'r'); + try { + const stat = await fh.stat(); + const start = Math.max(0, stat.size - byteLimit); + const length = stat.size - start; + const buf = Buffer.alloc(length); + await fh.read(buf, 0, length, start); + let text = buf.toString('utf8'); + // If we cut into the middle of a UTF-8 sequence, drop the partial char + const partialLead = text.match(/[\uD800-\uDBFF]$/); + if (partialLead) text = text.slice(0, -1); + // Drop a half first line so we never start mid-entry + const nl = text.indexOf('\n'); + if (start > 0 && nl !== -1) text = text.slice(nl + 1); + return { text, totalSize: stat.size, truncated: start > 0 }; + } finally { + await fh.close(); + } +} /** * Error logs routes factory @@ -17,38 +88,41 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) { const router = express.Router(); // Get error logs + // GET /api/v1/error-logs?tail=100&level=ERR + // - tail: cap on returned entries (default 100, max 500) + // - level: filter by level (ERR/WARN/INFO/DBG) — case-insensitive router.get('/error-logs', asyncHandler(async (req, res) => { if (!await exists(ERROR_LOG_FILE)) { - return success(res, { logs: [] }); + return success(res, { logs: [], totalSize: 0, truncated: false }); } - const logContent = await fsp.readFile(ERROR_LOG_FILE, 'utf8'); - const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim()); + let tailRaw = parseInt(req.query.tail, 10); + if (!Number.isFinite(tailRaw) || tailRaw <= 0) tailRaw = 100; + const tail = Math.min(tailRaw, MAX_TAIL); - const logs = logEntries.map(entry => { - const lines = entry.trim().split('\n'); - const firstLine = lines[0] || ''; - const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/); + const levelFilter = req.query.level ? String(req.query.level).toUpperCase() : null; - if (match) { - return { - timestamp: match[1], - context: match[2], - error: match[3] - }; - } - return null; - }).filter(Boolean); + const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES); + let logs = parseEntries(text); - success(res, { logs: logs.slice(-50).reverse() }); + if (levelFilter) { + logs = logs.filter(e => e.level === levelFilter); + } + + // Newest first; bounded by `tail` + logs = logs.slice(-tail).reverse(); + + success(res, { logs, totalSize, truncated, returned: logs.length }); }, 'error-logs-get')); // Clear error logs router.delete('/error-logs', asyncHandler(async (req, res) => { - if (await exists(ERROR_LOG_FILE)) { - await fsp.writeFile(ERROR_LOG_FILE, ''); + if (!await exists(ERROR_LOG_FILE)) { + return success(res, { message: 'Error logs cleared', cleared: 0 }); } - success(res, { message: 'Error logs cleared' }); + const before = await fsp.stat(ERROR_LOG_FILE).then(s => s.size).catch(() => 0); + await fsp.writeFile(ERROR_LOG_FILE, ''); + success(res, { message: 'Error logs cleared', clearedBytes: before }); }, 'error-logs-clear')); // Audit log @@ -56,7 +130,6 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) { const paginationParams = parsePaginationParams(req.query); const action = req.query.action || ''; if (paginationParams) { - // When paginating, fetch all matching entries and let pagination slice const entries = await auditLogger.query({ limit: Number.MAX_SAFE_INTEGER, offset: 0, action }); const result = paginate(entries, paginationParams); success(res, { entries: result.data, pagination: result.pagination }); @@ -75,3 +148,9 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) { return router; }; + +// Exported for unit testing +module.exports.parseEntries = parseEntries; +module.exports.readTailBytes = readTailBytes; +module.exports.MAX_TAIL = MAX_TAIL; +module.exports.MAX_TAIL_BYTES = MAX_TAIL_BYTES; \ No newline at end of file diff --git a/status/index.html b/status/index.html index 6b09ffc..186a5ca 100644 --- a/status/index.html +++ b/status/index.html @@ -203,6 +203,7 @@
+ 📄 Logs Page diff --git a/status/js/error-logs.js b/status/js/error-logs.js index 169efc0..8d00a17 100644 --- a/status/js/error-logs.js +++ b/status/js/error-logs.js @@ -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', '

📋 Error Logs

Loading error logs...
'); + const MAX_LEVELS = ['ERR', 'WRN', 'INF', 'DBG']; + + injectModal('error-log-modal', [ + '
', + '
', + '
', + '

📋 Error Logs

', + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + '
', + '
', + '
', + '
Loading error logs...
', + '
', + '
', + '
', + ].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 = '
Loading error logs...
'; - + 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 = '
✅ No errors logged! Everything is working smoothly.
'; } 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 ? `${escapeHtml(log.context)}: ` : ''; + const msg = escapeHtml(log.message || ''); return ` -
- ${date} - ERROR +
+ ${escapeHtml(date)} + ${escapeHtml(lvl)}
- ${escapeHtml(log.context)}: ${escapeHtml(log.error)} - ${log.details ? `
${escapeHtml(log.details)}` : ''} + ${ctx}${msg} + ${details ? `
stack + request context
${details}
` : ''}
`; }).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 = '
❌ Failed to load error logs
'; } @@ -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); -})(); +})(); \ No newline at end of file diff --git a/status/js/logs-page.js b/status/js/logs-page.js new file mode 100644 index 0000000..b32c247 --- /dev/null +++ b/status/js/logs-page.js @@ -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 = '
Loading error log…
'; + meta.textContent = ''; + try { + const data = await fetchJson('/api/v1/error-logs?' + qs); + const logs = data.logs || []; + if (logs.length === 0) { + out.innerHTML = '
✅ No errors logged
'; + } 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 ` +
+ ${escapeHtml(date)} + ${escapeHtml(lvl)} +
+ ${escapeHtml(log.context || '')}: ${escapeHtml(log.message || '')} + ${details ? `
stack + request context
${details}
` : ''} +
+
+ `; + }).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 = `
❌ ${escapeHtml(err.message)}
`; + } + } + + 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 = '
Loading containers…
'; + 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 ``; + }).join(''); + if (containers.length === 0) { + out.innerHTML = '
No containers running
'; + return; + } + loadContainerLog(); + } catch (err) { + out.innerHTML = `
❌ ${escapeHtml(err.message)}
`; + } + } + + 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 = '
Select a container
'; + return; + } + out.innerHTML = '
Loading container logs…
'; + 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 = '
No log lines
'; + } else { + out.innerHTML = logs.map(l => { + const cls = l.stream === 'stderr' ? 'error' : 'info'; + const ts = l.timestamp || (data.logs.length ? '' : ''); + return ` +
+ ${ts ? `${escapeHtml(new Date(ts).toLocaleString())}` : ''} + ${l.stream === 'stderr' ? 'ERR' : 'OUT'} +
${escapeHtml(l.text)}
+
+ `; + }).join(''); + } + const containerName = data.containerName || ''; + document.getElementById('container-meta').textContent = `${escapeHtml(containerName)} · ${logs.length} lines`; + } catch (err) { + out.innerHTML = `
❌ ${escapeHtml(err.message)}
`; + } + } + + 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'); +})(); \ No newline at end of file diff --git a/status/logs.html b/status/logs.html new file mode 100644 index 0000000..c8ea8bc --- /dev/null +++ b/status/logs.html @@ -0,0 +1,99 @@ + + + + +DashCaddy — Logs + + + + + +
+
+ ← Back +

📋 Logs

+
+ + +
+
+ + +
+ + + + + +
+ + + + +
+
Loading…
+
+
+ + + + \ No newline at end of file