Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bb3f6db8 |
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,80 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const { exists } = require('../src/utilities/fs-helpers');
|
const { exists } = require('../src/utilities/fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { success } = require('../src/utils/responses');
|
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
|
||||||
|
* <stack trace lines, if any>
|
||||||
|
* 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
|
* Error logs routes factory
|
||||||
@@ -17,38 +88,41 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get error logs
|
// 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) => {
|
router.get('/error-logs', asyncHandler(async (req, res) => {
|
||||||
if (!await exists(ERROR_LOG_FILE)) {
|
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');
|
let tailRaw = parseInt(req.query.tail, 10);
|
||||||
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
|
if (!Number.isFinite(tailRaw) || tailRaw <= 0) tailRaw = 100;
|
||||||
|
const tail = Math.min(tailRaw, MAX_TAIL);
|
||||||
|
|
||||||
const logs = logEntries.map(entry => {
|
const levelFilter = req.query.level ? String(req.query.level).toUpperCase() : null;
|
||||||
const lines = entry.trim().split('\n');
|
|
||||||
const firstLine = lines[0] || '';
|
|
||||||
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
|
|
||||||
|
|
||||||
if (match) {
|
const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES);
|
||||||
return {
|
let logs = parseEntries(text);
|
||||||
timestamp: match[1],
|
|
||||||
context: match[2],
|
|
||||||
error: match[3]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}).filter(Boolean);
|
|
||||||
|
|
||||||
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'));
|
}, 'error-logs-get'));
|
||||||
|
|
||||||
// Clear error logs
|
// Clear error logs
|
||||||
router.delete('/error-logs', asyncHandler(async (req, res) => {
|
router.delete('/error-logs', asyncHandler(async (req, res) => {
|
||||||
if (await exists(ERROR_LOG_FILE)) {
|
if (!await exists(ERROR_LOG_FILE)) {
|
||||||
await fsp.writeFile(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'));
|
}, 'error-logs-clear'));
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
@@ -56,7 +130,6 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
|
|||||||
const paginationParams = parsePaginationParams(req.query);
|
const paginationParams = parsePaginationParams(req.query);
|
||||||
const action = req.query.action || '';
|
const action = req.query.action || '';
|
||||||
if (paginationParams) {
|
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 entries = await auditLogger.query({ limit: Number.MAX_SAFE_INTEGER, offset: 0, action });
|
||||||
const result = paginate(entries, paginationParams);
|
const result = paginate(entries, paginationParams);
|
||||||
success(res, { entries: result.data, pagination: result.pagination });
|
success(res, { entries: result.data, pagination: result.pagination });
|
||||||
@@ -75,3 +148,9 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
|
|||||||
|
|
||||||
return router;
|
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;
|
||||||
@@ -203,6 +203,7 @@
|
|||||||
<div class="tools-section-items">
|
<div class="tools-section-items">
|
||||||
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
|
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
|
||||||
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
||||||
|
<a id="view-logs-page" aria-label="Dedicated logs page" href="/logs.html" target="_blank" rel="noopener" style="text-decoration:none;color:inherit">📄 Logs Page</a>
|
||||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||||
|
|||||||
+81
-9
@@ -1,40 +1,110 @@
|
|||||||
// ========== ERROR LOG VIEWER ==========
|
// ========== 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() {
|
(function() {
|
||||||
// Inject modal HTML
|
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"><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>');
|
|
||||||
|
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 modal = document.getElementById('error-log-modal');
|
||||||
const content = document.getElementById('error-log-content');
|
const content = document.getElementById('error-log-content');
|
||||||
|
const meta = document.getElementById('error-log-meta');
|
||||||
const viewBtn = document.getElementById('view-error-logs');
|
const viewBtn = document.getElementById('view-error-logs');
|
||||||
const refreshBtn = document.getElementById('error-log-refresh');
|
const refreshBtn = document.getElementById('error-log-refresh');
|
||||||
const clearBtn = document.getElementById('error-log-clear');
|
const clearBtn = document.getElementById('error-log-clear');
|
||||||
const closeBtn = document.getElementById('error-log-close');
|
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() {
|
async function loadErrorLogs() {
|
||||||
content.innerHTML = '<div class="logs-loading">Loading error logs...</div>';
|
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 {
|
try {
|
||||||
const response = await fetch('/api/v1/error-logs');
|
const response = await fetch('/api/v1/error-logs?' + qs);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success && data.logs) {
|
if (data.success && data.logs) {
|
||||||
if (data.logs.length === 0) {
|
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>';
|
content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--muted);">✅ No errors logged! Everything is working smoothly.</div>';
|
||||||
} else {
|
} else {
|
||||||
content.innerHTML = data.logs.map(log => {
|
content.innerHTML = data.logs.map((log, idx) => {
|
||||||
const date = new Date(log.timestamp).toLocaleString();
|
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 `
|
return `
|
||||||
<div class="log-entry error">
|
<div class="${levelClass(log.level)}">
|
||||||
<span class="log-timestamp">${date}</span>
|
<span class="log-timestamp">${escapeHtml(date)}</span>
|
||||||
<span class="log-level">ERROR</span>
|
<span class="log-level">${escapeHtml(lvl)}</span>
|
||||||
<div class="log-message">
|
<div class="log-message">
|
||||||
<strong>${escapeHtml(log.context)}</strong>: ${escapeHtml(log.error)}
|
${ctx}${msg}
|
||||||
${log.details ? `<br><small style="opacity: 0.7;">${escapeHtml(log.details)}</small>` : ''}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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 {
|
} else {
|
||||||
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
||||||
}
|
}
|
||||||
@@ -68,5 +138,7 @@
|
|||||||
|
|
||||||
refreshBtn?.addEventListener('click', loadErrorLogs);
|
refreshBtn?.addEventListener('click', loadErrorLogs);
|
||||||
clearBtn?.addEventListener('click', clearErrorLogs);
|
clearBtn?.addEventListener('click', clearErrorLogs);
|
||||||
|
levelSelect?.addEventListener('change', loadErrorLogs);
|
||||||
|
tailSelect?.addEventListener('change', loadErrorLogs);
|
||||||
wireModal(modal, closeBtn);
|
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');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>DashCaddy — Logs</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<style>
|
||||||
|
body.logs-page { padding: 0; margin: 0; background: var(--bg, #0e1116); color: var(--fg, #e6e6e6); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.logs-wrap { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
|
||||||
|
.logs-top { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--card-bg, #161a22); border-radius: 8px; margin-bottom: 16px; }
|
||||||
|
.logs-top h1 { margin: 0; font-size: 1.1rem; }
|
||||||
|
.logs-tabs { display: flex; gap: 6px; margin-left: auto; }
|
||||||
|
.logs-tabs button { padding: 6px 12px; background: transparent; border: 1px solid var(--border, #2a2f3a); color: inherit; border-radius: 6px; cursor: pointer; font: inherit; font-size: .85rem; }
|
||||||
|
.logs-tabs button.active { background: var(--accent, #4f8cff); color: white; border-color: transparent; }
|
||||||
|
.logs-controls { display: flex; gap: 8px; align-items: center; padding: 10px 16px; background: var(--card-bg, #161a22); border-radius: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.logs-controls select, .logs-controls input { background: var(--bg, #0e1116); color: inherit; border: 1px solid var(--border, #2a2f3a); padding: 4px 8px; border-radius: 4px; font: inherit; font-size: .85rem; }
|
||||||
|
.logs-controls button { padding: 6px 12px; background: var(--accent, #4f8cff); color: white; border: none; border-radius: 4px; cursor: pointer; font: inherit; font-size: .85rem; }
|
||||||
|
.logs-controls button.danger { background: color-mix(in srgb, #ff5555 25%, transparent); border: 1px solid #ff5555; color: #ff5555; }
|
||||||
|
.logs-meta { color: var(--muted, #8a93a6); font-size: .8rem; margin-left: auto; }
|
||||||
|
.logs-output { background: var(--card-bg, #161a22); border-radius: 8px; padding: 12px; max-height: 70vh; overflow-y: auto; font-size: .85rem; line-height: 1.4; }
|
||||||
|
.logs-output .log-entry { padding: 8px 10px; border-bottom: 1px solid var(--border, #2a2f3a); }
|
||||||
|
.logs-output .log-entry:last-child { border-bottom: none; }
|
||||||
|
.logs-output .log-entry.error { border-left: 3px solid #ff5555; }
|
||||||
|
.logs-output .log-entry.warn { border-left: 3px solid #f0b400; }
|
||||||
|
.logs-output .log-entry.info { border-left: 3px solid #4f8cff; }
|
||||||
|
.logs-output .log-entry.debug { border-left: 3px solid #8a93a6; }
|
||||||
|
.logs-output .log-timestamp { color: var(--muted, #8a93a6); margin-right: 8px; font-size: .75rem; }
|
||||||
|
.logs-output .log-level { display: inline-block; padding: 0 6px; border-radius: 3px; font-size: .7rem; font-weight: 600; margin-right: 8px; min-width: 38px; text-align: center; }
|
||||||
|
.log-entry.error .log-level { background: #ff5555; color: white; }
|
||||||
|
.log-entry.warn .log-level { background: #f0b400; color: black; }
|
||||||
|
.log-entry.info .log-level { background: #4f8cff; color: white; }
|
||||||
|
.log-entry.debug .log-level { background: #555; color: white; }
|
||||||
|
.logs-output .log-message pre { margin: 6px 0 0; font-size: .75rem; padding: 6px 8px; background: rgba(0,0,0,0.25); border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.logs-output .empty { color: var(--muted, #8a93a6); text-align: center; padding: 40px; }
|
||||||
|
.logs-loading { color: var(--muted, #8a93a6); text-align: center; padding: 40px; }
|
||||||
|
.logs-back { padding: 4px 10px; background: transparent; border: 1px solid var(--border, #2a2f3a); color: inherit; border-radius: 4px; cursor: pointer; font: inherit; font-size: .85rem; text-decoration: none; }
|
||||||
|
.container-pick { display: flex; gap: 6px; align-items: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="logs-page">
|
||||||
|
<div class="logs-wrap">
|
||||||
|
<div class="logs-top">
|
||||||
|
<a href="/" class="logs-back">← Back</a>
|
||||||
|
<h1>📋 Logs</h1>
|
||||||
|
<div class="logs-tabs">
|
||||||
|
<button id="tab-error" class="active">Error log</button>
|
||||||
|
<button id="tab-container">Container</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error log controls -->
|
||||||
|
<div id="error-controls" class="logs-controls">
|
||||||
|
<label>Level:
|
||||||
|
<select id="level">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="ERR">Errors</option>
|
||||||
|
<option value="WRN">Warnings</option>
|
||||||
|
<option value="INF">Info</option>
|
||||||
|
<option value="DBG">Debug</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tail:
|
||||||
|
<select id="tail">
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100" selected>100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="refresh">🔄 Refresh</button>
|
||||||
|
<button id="clear" class="danger">🗑️ Clear</button>
|
||||||
|
<span class="logs-meta" id="meta"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Container log controls -->
|
||||||
|
<div id="container-controls" class="logs-controls" style="display:none">
|
||||||
|
<label>Container:
|
||||||
|
<select id="container-select"></select>
|
||||||
|
</label>
|
||||||
|
<label>Tail:
|
||||||
|
<select id="container-tail">
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="200" selected>200</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="container-refresh">🔄 Refresh</button>
|
||||||
|
<span class="logs-meta" id="container-meta"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logs-output" id="output">
|
||||||
|
<div class="logs-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/logs-page.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user