156 lines
5.9 KiB
JavaScript
156 lines
5.9 KiB
JavaScript
const express = require('express');
|
|
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
|
|
* <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
|
|
* @param {Object} deps - Explicit dependencies
|
|
* @param {string} deps.ERROR_LOG_FILE - Path to error log file
|
|
* @param {Object} deps.auditLogger - Audit logger instance
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @returns {express.Router}
|
|
*/
|
|
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: [], totalSize: 0, truncated: false });
|
|
}
|
|
|
|
let tailRaw = parseInt(req.query.tail, 10);
|
|
if (!Number.isFinite(tailRaw) || tailRaw <= 0) tailRaw = 100;
|
|
const tail = Math.min(tailRaw, MAX_TAIL);
|
|
|
|
const levelFilter = req.query.level ? String(req.query.level).toUpperCase() : null;
|
|
|
|
const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES);
|
|
let logs = parseEntries(text);
|
|
|
|
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)) {
|
|
return success(res, { message: 'Error logs cleared', cleared: 0 });
|
|
}
|
|
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
|
|
router.get('/audit-logs', asyncHandler(async (req, res) => {
|
|
const paginationParams = parsePaginationParams(req.query);
|
|
const action = req.query.action || '';
|
|
if (paginationParams) {
|
|
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 });
|
|
} else {
|
|
const limit = parseInt(req.query.limit) || 50;
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
const entries = await auditLogger.query({ limit, offset, action });
|
|
success(res, { entries });
|
|
}
|
|
}, 'audit-log'));
|
|
|
|
router.delete('/audit-logs', asyncHandler(async (req, res) => {
|
|
await auditLogger.clear();
|
|
success(res, { message: 'Audit log cleared' });
|
|
}, 'audit-log-clear'));
|
|
|
|
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; |