Compare commits
6
Commits
295c63ce94
...
dc/DC-051
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bb3f6db8 | ||
|
|
5f95fdcf70 | ||
|
|
45cfa83bad | ||
|
|
6d875e4631 | ||
|
|
4555d829ac | ||
|
|
e99413150e |
@@ -3,3 +3,4 @@ coverage/
|
||||
dist/
|
||||
build/
|
||||
*.min.js
|
||||
static-sites/
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Tests for caddy-upstream-watcher.
|
||||
*
|
||||
* Mock-driven: we stub fs (for /etc/caddy/sites scan + state file) and http/https
|
||||
* (for the probe). Tests cover site parsing, probe happy/sad path, the 5-minute
|
||||
* "dead" threshold, mute toggle, and incident integration with healthChecker.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const Module = require('module');
|
||||
|
||||
// Mock fs with controllable behavior.
|
||||
const fsState = {
|
||||
files: {}, // path -> string content
|
||||
exists: {}, // path -> bool
|
||||
writeLog: [], // writes
|
||||
};
|
||||
|
||||
jest.mock('fs', () => {
|
||||
const real = jest.requireActual('fs');
|
||||
return {
|
||||
...real,
|
||||
existsSync: jest.fn((p) => fsState.exists[p] !== undefined ? fsState.exists[p] : (fsState.files[p] !== undefined)),
|
||||
readFileSync: jest.fn((p) => {
|
||||
if (fsState.files[p] === undefined) {
|
||||
const e = new Error(`ENOENT: ${p}`);
|
||||
e.code = 'ENOENT';
|
||||
throw e;
|
||||
}
|
||||
return fsState.files[p];
|
||||
}),
|
||||
readdirSync: jest.fn((p) => Object.keys(fsState.files).filter(f => f.startsWith(p + '/')).map(f => f.substring(p.length + 1))),
|
||||
writeFileSync: jest.fn((p, content) => {
|
||||
fsState.writeLog.push({ p, content });
|
||||
fsState.files[p] = content;
|
||||
fsState.exists[p] = true;
|
||||
}),
|
||||
mkdirSync: jest.fn(),
|
||||
renameSync: jest.fn((src, dst) => {
|
||||
fsState.files[dst] = fsState.files[src];
|
||||
fsState.exists[dst] = true;
|
||||
delete fsState.files[src];
|
||||
delete fsState.exists[src];
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Mock http/https request to control probe responses.
|
||||
const probeQueue = []; // each entry: { kind: 'ok'|'err'|'timeout'|'code', statusCode? }
|
||||
jest.mock('http', () => ({
|
||||
request: jest.fn((opts, cb) => {
|
||||
const entry = probeQueue.shift() || { kind: 'ok', statusCode: 200 };
|
||||
const handlers = {};
|
||||
const res = {
|
||||
statusCode: entry.statusCode || 200,
|
||||
headers: { server: 'mock' },
|
||||
resume: () => {},
|
||||
on: (e, fn) => { handlers[e] = fn; }
|
||||
};
|
||||
const req = {
|
||||
on: jest.fn((e, fn) => { handlers[e] = fn; }),
|
||||
end: jest.fn(() => {
|
||||
if (entry.kind === 'err') {
|
||||
handlers.error && handlers.error(new Error(entry.message || 'connect ECONNREFUSED'));
|
||||
return;
|
||||
}
|
||||
if (entry.kind === 'timeout') {
|
||||
handlers.timeout && handlers.timeout();
|
||||
return;
|
||||
}
|
||||
cb(res);
|
||||
if (handlers.end) handlers.end();
|
||||
}),
|
||||
destroy: jest.fn()
|
||||
};
|
||||
return req;
|
||||
})
|
||||
}));
|
||||
jest.mock('https', () => ({
|
||||
request: jest.fn((opts, cb) => {
|
||||
const entry = probeQueue.shift() || { kind: 'ok', statusCode: 200 };
|
||||
const handlers = {};
|
||||
const res = {
|
||||
statusCode: entry.statusCode || 200,
|
||||
headers: { server: 'mock-https' },
|
||||
resume: () => {},
|
||||
on: (e, fn) => { handlers[e] = fn; }
|
||||
};
|
||||
const req = {
|
||||
on: jest.fn((e, fn) => { handlers[e] = fn; }),
|
||||
end: jest.fn(() => {
|
||||
if (entry.kind === 'err') {
|
||||
handlers.error && handlers.error(new Error(entry.message || 'TLS error'));
|
||||
return;
|
||||
}
|
||||
cb(res);
|
||||
if (handlers.end) handlers.end();
|
||||
}),
|
||||
destroy: jest.fn()
|
||||
};
|
||||
return req;
|
||||
})
|
||||
}));
|
||||
|
||||
// Reset fs mock state between tests.
|
||||
beforeEach(() => {
|
||||
fsState.files = {};
|
||||
fsState.exists = {};
|
||||
fsState.writeLog = [];
|
||||
probeQueue.length = 0;
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('CaddyUpstreamWatcher', () => {
|
||||
const SITES = '/etc/caddy/sites';
|
||||
const STATE = '/tmp/caddy-upstreams-test.json';
|
||||
|
||||
function seedSites(files) {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fsState.files[SITES + '/' + name] = content;
|
||||
fsState.exists[SITES + '/' + name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function loadWatcher() {
|
||||
process.env.CADDY_UPSTREAMS_STATE_FILE = STATE;
|
||||
process.env.CADDY_SITES_DIR = SITES;
|
||||
// Disable the singleton's auto-write so we can call _saveState manually.
|
||||
const mod = require('../src/monitoring/caddy-upstream-watcher');
|
||||
return { mod, w: mod.CaddyUpstreamWatcher ? new mod.CaddyUpstreamWatcher() : mod };
|
||||
}
|
||||
|
||||
test('parses reverse_proxy directives from /etc/caddy/sites/*', async () => {
|
||||
seedSites({
|
||||
'arch.sami': `arch.sami {\n reverse_proxy 100.120.159.34:5000 { health_uri /api/stats }\n}`,
|
||||
'appt.sami': `appt.sami {\n reverse_proxy http://100.81.59.99:5232\n}`,
|
||||
'zap.sami': `zap.sami {\n reverse_proxy 10.0.0.5:8080\n reverse_proxy 10.0.0.6:8080 # multiple upstreams in same site block\n}`
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const snap = w.snapshot();
|
||||
const hosts = snap.upstreams.map(u => u.host).sort();
|
||||
expect(hosts).toEqual(['10.0.0.5:8080', '10.0.0.6:8080', '100.120.159.34:5000', '100.81.59.99:5232']);
|
||||
expect(snap.upstreams.find(u => u.host === '100.120.159.34:5000').site).toBe('arch.sami');
|
||||
expect(snap.upstreams.find(u => u.host === '100.81.59.99:5232').site).toBe('appt.sami');
|
||||
});
|
||||
|
||||
test('ignores non-site files and unparseable entries', async () => {
|
||||
seedSites({
|
||||
'README.md': '# documentation\nreverse_proxy 1.2.3.4:9999\n', // not a site file
|
||||
'garbage.sami': 'not a caddyfile\n', // no reverse_proxy
|
||||
'good.sami': 'good.sami {\n reverse_proxy 1.2.3.4:9999\n}\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const hosts = w.snapshot().upstreams.map(u => u.host);
|
||||
expect(hosts).toEqual(['1.2.3.4:9999']);
|
||||
});
|
||||
|
||||
test('handles real prod-style filenames: zap.sami-ahmed.net, samitest.space, blocks.cryptographic-triangles.org', async () => {
|
||||
// These are the actual file names in production /etc/caddy/sites/ —
|
||||
// extension is .net / .space / .org, NOT .sami/.caddy/.conf. The old
|
||||
// file-extension filter would skip them silently.
|
||||
seedSites({
|
||||
'zap.sami-ahmed.net': 'zap.sami-ahmed.net {\n reverse_proxy localhost:8088\n}\n',
|
||||
'samitest.space': 'samitest.space {\n reverse_proxy 100.120.159.34:8080 {}\n}\n',
|
||||
'blocks.cryptographic-triangles.org': 'blocks.cryptographic-triangles.org {\n\treverse_proxy localhost:3052 {}\n}\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const snap = w.snapshot();
|
||||
const byHost = Object.fromEntries(snap.upstreams.map(u => [u.host, u.site]));
|
||||
expect(byHost['localhost:8088']).toBe('zap.sami-ahmed.net');
|
||||
expect(byHost['100.120.159.34:8080']).toBe('samitest.space');
|
||||
expect(byHost['localhost:3052']).toBe('blocks.cryptographic-triangles.org');
|
||||
});
|
||||
|
||||
test('drops upstreams that disappear from the sites dir', async () => {
|
||||
seedSites({
|
||||
'arch.sami': 'arch.sami {\n reverse_proxy 1.1.1.1:5000\n}\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
expect(w.upstreams.size).toBe(1);
|
||||
fsState.files = {}; // wipe
|
||||
fsState.exists = {};
|
||||
await w.scanSites();
|
||||
expect(w.upstreams.size).toBe(0);
|
||||
});
|
||||
|
||||
test('healthy probe updates state and does not open an incident', async () => {
|
||||
seedSites({ 'good.sami': 'good.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = { createIncident: jest.fn(), incidents: [] };
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
await w._probeOne(w.upstreams.values().next().value);
|
||||
const snap = w.snapshot();
|
||||
expect(snap.upstreams[0].status).toBe('up');
|
||||
expect(snap.upstreams[0].lastSuccessAt).toBeTruthy();
|
||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth-walled 4xx counts as healthy (proves the upstream answered)', async () => {
|
||||
seedSites({ 'auth.sami': 'auth.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'ok', statusCode: 401 });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
await w._probeOne(w.upstreams.values().next().value);
|
||||
expect(w.snapshot().upstreams[0].status).toBe('up');
|
||||
});
|
||||
|
||||
test('first failure flips status to down but does NOT open an incident (under 5min)', async () => {
|
||||
seedSites({ 'bad.sami': 'bad.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED' });
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = { createIncident: jest.fn(), incidents: [] };
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
u.lastSuccessAt = new Date(Date.now() - 30000).toISOString(); // 30s ago it was healthy
|
||||
await w._probeOne(u);
|
||||
const snap = w.snapshot();
|
||||
expect(snap.upstreams[0].status).toBe('down');
|
||||
expect(snap.upstreams[0].failingForMs).toBeLessThan(5 * 60 * 1000);
|
||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('after 5 minutes of consecutive failures an incident is opened', async () => {
|
||||
seedSites({ 'dead.sami': 'dead.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
const { w } = loadWatcher();
|
||||
const incidents = [];
|
||||
const fakeHealthChecker = {
|
||||
createIncident: jest.fn((serviceId, type, message, status) => {
|
||||
incidents.push({ serviceId, type, message, status });
|
||||
}),
|
||||
incidents: []
|
||||
};
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
// Simulate lastSuccessAt being 6 minutes ago so failingForMs exceeds DEAD_AFTER_MS.
|
||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
expect(fakeHealthChecker.createIncident).toHaveBeenCalledTimes(1);
|
||||
expect(fakeHealthChecker.createIncident.mock.calls[0][1]).toBe('caddy-upstream-dead');
|
||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not duplicate incidents for the same upstream', async () => {
|
||||
seedSites({ 'dead.sami': 'dead.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
// Queue up 3 errors so each probe fails.
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = {
|
||||
createIncident: jest.fn(),
|
||||
incidents: []
|
||||
};
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
await w._probeOne(u);
|
||||
await w._probeOne(u);
|
||||
expect(fakeHealthChecker.createIncident).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('recovery resolves the open incident after RESOLVED_AFTER_MS', async () => {
|
||||
seedSites({ 'flap.sami': 'flap.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' }); // trip dead
|
||||
probeQueue.push({ kind: 'ok', statusCode: 200 }); // recovery
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = {
|
||||
createIncident: jest.fn(),
|
||||
resolveIncident: jest.fn(),
|
||||
incidents: []
|
||||
};
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
// Trip the dead state
|
||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(true);
|
||||
// Simulate a recovery — lastFailureAt is 2 min ago, now healthy
|
||||
u.lastFailureAt = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
expect(fakeHealthChecker.resolveIncident).toHaveBeenCalledTimes(1);
|
||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(false);
|
||||
});
|
||||
|
||||
test('mute suppresses probing and hides upstream in snapshot status', async () => {
|
||||
seedSites({ 'noisy.sami': 'noisy.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
w.setMuted('1.1.1.1:80', true);
|
||||
expect(w.isMuted('1.1.1.1:80')).toBe(true);
|
||||
const snap = w.snapshot();
|
||||
expect(snap.upstreams[0].status).toBe('muted');
|
||||
expect(snap.upstreams[0].muted).toBe(true);
|
||||
// probe tick should skip muted
|
||||
await w._tick();
|
||||
// lastCheckedAt should NOT have advanced because no probe was issued
|
||||
expect(snap.upstreams[0].lastCheckedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('unmute resets failure counters so a recently-recovered upstream is not immediately re-incidented', async () => {
|
||||
seedSites({ 'flap.sami': 'flap.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
u.consecutiveFailures = 42;
|
||||
u.lastError = 'old failure';
|
||||
u.lastFailureAt = new Date().toISOString();
|
||||
u.status = 'down';
|
||||
w.setMuted('1.1.1.1:80', true);
|
||||
w.setMuted('1.1.1.1:80', false);
|
||||
expect(u.consecutiveFailures).toBe(0);
|
||||
expect(u.status).toBe('unknown');
|
||||
expect(u.lastError).toBeNull();
|
||||
});
|
||||
|
||||
test('snapshot sorts dead > down > muted > up > unknown', async () => {
|
||||
seedSites({
|
||||
'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n',
|
||||
'b.sami': 'b.sami { reverse_proxy 2.2.2.2:80 }\n',
|
||||
'c.sami': 'c.sami { reverse_proxy 3.3.3.3:80 }\n',
|
||||
'd.sami': 'd.sami { reverse_proxy 4.4.4.4:80 }\n',
|
||||
'e.sami': 'e.sami { reverse_proxy 5.5.5.5:80 }\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const all = Array.from(w.upstreams.values());
|
||||
// 1.1.1.1:80 -> up (just succeeded)
|
||||
all.find(u => u.host === '1.1.1.1:80').status = 'up';
|
||||
all.find(u => u.host === '1.1.1.1:80').lastSuccessAt = new Date().toISOString();
|
||||
// 2.2.2.2:80 -> down (recent — last success 30s ago)
|
||||
all.find(u => u.host === '2.2.2.2:80').status = 'down';
|
||||
all.find(u => u.host === '2.2.2.2:80').lastSuccessAt = new Date(Date.now() - 30000).toISOString();
|
||||
// 3.3.3.3:80 -> muted
|
||||
w.muted.add('3.3.3.3:80');
|
||||
// 4.4.4.4:80 -> dead (last success 7min ago, never recovered)
|
||||
const dead = all.find(u => u.host === '4.4.4.4:80');
|
||||
dead.status = 'down';
|
||||
dead.lastSuccessAt = new Date(Date.now() - 7 * 60 * 1000).toISOString();
|
||||
// 5.5.5.5:80 -> unknown (no probes yet)
|
||||
const snap = w.snapshot();
|
||||
const order = snap.upstreams.map(u => u.host);
|
||||
// Expected: dead first, then down, then muted, then up, then unknown
|
||||
expect(order).toEqual(['4.4.4.4:80', '2.2.2.2:80', '3.3.3.3:80', '1.1.1.1:80', '5.5.5.5:80']);
|
||||
});
|
||||
|
||||
test('persists muted list to state file', async () => {
|
||||
seedSites({ 'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
w.setMuted('1.1.1.1:80', true);
|
||||
// _saveState writes to STATE + '.tmp' then renames. Look for the .tmp
|
||||
// write since that's the actual writeFileSync call (rename is silent).
|
||||
const writes = fsState.writeLog.filter(w => w.p === STATE + '.tmp' || w.p === STATE);
|
||||
expect(writes.length).toBeGreaterThan(0);
|
||||
const last = writes[writes.length - 1];
|
||||
const data = JSON.parse(last.content);
|
||||
expect(data.muted).toContain('1.1.1.1:80');
|
||||
});
|
||||
|
||||
test('reload from state file restores muted list', async () => {
|
||||
// Pre-seed a state file with a muted host
|
||||
fsState.files[STATE] = JSON.stringify({
|
||||
muted: ['99.99.99.99:80'],
|
||||
upstreams: { '99.99.99.99:80': { ip: '99.99.99.99', port: '80', site: 'old.sami', siteFile: 'old.sami' } }
|
||||
});
|
||||
fsState.exists[STATE] = true;
|
||||
// And the matching site file
|
||||
seedSites({ 'old.sami': 'old.sami { reverse_proxy 99.99.99.99:80 }\n' });
|
||||
|
||||
process.env.CADDY_UPSTREAMS_STATE_FILE = STATE;
|
||||
process.env.CADDY_SITES_DIR = SITES;
|
||||
const mod = require('../src/monitoring/caddy-upstream-watcher');
|
||||
const w = mod.CaddyUpstreamWatcher ? new mod.CaddyUpstreamWatcher() : mod;
|
||||
expect(w.isMuted('99.99.99.99:80')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* DC-048 — disk-settings-loader unit tests
|
||||
*
|
||||
* Covers:
|
||||
* - applies persisted values to process.env (happy path)
|
||||
* - explicit process.env wins over persisted file
|
||||
* - missing file → no-op, no throw
|
||||
* - malformed JSON → no throw, engine defaults preserved
|
||||
* - non-numeric values rejected, not silently applied
|
||||
* - empty/null/undefined values skipped
|
||||
* - idempotent across calls (once-guard)
|
||||
* - all six mapped keys land in env when persisted
|
||||
*
|
||||
* Run with: npx jest __tests__/disk-settings-loader.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Snapshot env at module load so we can restore in afterEach. We always
|
||||
// UNSET the loader-managed keys (HEALTH_*, AUDIT_*, BACKUP_*, CONTAINER_STATS_*)
|
||||
// at the start of each test, regardless of whether they were set at
|
||||
// snapshot time, because the loader mutates process.env and stale values
|
||||
// from prior tests would silently change behavior.
|
||||
const LOADER_KEYS = [
|
||||
'HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES', 'HEALTH_HISTORY_RETENTION',
|
||||
'AUDIT_MAX_ENTRIES', 'BACKUP_MAX_STORAGE_BYTES', 'CONTAINER_STATS_MAX_ENTRIES',
|
||||
];
|
||||
const ORIGINAL_ENV = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([k]) => LOADER_KEYS.includes(k) || k === 'DATA_DIR'),
|
||||
);
|
||||
|
||||
function restoreEnv() {
|
||||
// Loader-managed keys: ALWAYS reset to ORIGINAL_ENV state (or undefined).
|
||||
// This is critical — without it, env vars set by a prior test would leak
|
||||
// into the next test as "env-already-set" and the loader would skip
|
||||
// values that the test expects to be applied.
|
||||
for (const k of LOADER_KEYS) {
|
||||
if (ORIGINAL_ENV[k] === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = ORIGINAL_ENV[k];
|
||||
}
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
}
|
||||
|
||||
// Temp data dir for filesystem-driven tests.
|
||||
const TMP_DATA_DIR = '/tmp/dashcaddy-disk-settings-loader-test';
|
||||
function makeDataDir() {
|
||||
try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ }
|
||||
fs.mkdirSync(TMP_DATA_DIR, { recursive: true });
|
||||
}
|
||||
function writePersisted(obj) {
|
||||
fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), JSON.stringify(obj));
|
||||
}
|
||||
|
||||
describe('disk-settings-loader', () => {
|
||||
beforeEach(() => {
|
||||
restoreEnv();
|
||||
makeDataDir();
|
||||
// Wipe the once-guard between tests so each case sees a fresh loader run.
|
||||
// We must require the module AFTER clearing the cache.
|
||||
delete require.cache[require.resolve('../src/config/disk-settings-loader')];
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
loader._resetForTesting();
|
||||
// Force hasRun reset (jest's module loader is not always cleared by the
|
||||
// require.cache delete — explicit call is the contract for the loader).
|
||||
// Note: loader._resetForTesting is the authoritative reset path.
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnv();
|
||||
try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ }
|
||||
});
|
||||
|
||||
it('applies all six persisted values to process.env', () => {
|
||||
writePersisted({
|
||||
healthCheckInterval: 45000,
|
||||
healthMaxEntries: 750,
|
||||
healthRetentionDays: 14,
|
||||
statsMaxEntries: 800,
|
||||
auditMaxEntries: 1500,
|
||||
backupMaxStorageBytes: 2147483648,
|
||||
});
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
|
||||
expect(result.applied).toHaveLength(6);
|
||||
expect(process.env.HEALTH_CHECK_INTERVAL).toBe('45000');
|
||||
expect(process.env.HEALTH_MAX_ENTRIES).toBe('750');
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
||||
expect(process.env.CONTAINER_STATS_MAX_ENTRIES).toBe('800');
|
||||
expect(process.env.AUDIT_MAX_ENTRIES).toBe('1500');
|
||||
expect(process.env.BACKUP_MAX_STORAGE_BYTES).toBe('2147483648');
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not throw when disk-settings.json is missing', () => {
|
||||
// TMP_DATA_DIR exists but no disk-settings.json inside it.
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
||||
const result = loader({ dataDir: TMP_DATA_DIR }); // idempotent
|
||||
expect(result.applied).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not throw on malformed JSON; logs to stderr', () => {
|
||||
fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), '{ this is not json');
|
||||
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(result.applied).toEqual([]);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WARN: failed to parse'),
|
||||
);
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('explicit process.env wins over persisted file', () => {
|
||||
process.env.HEALTH_HISTORY_RETENTION = '90';
|
||||
writePersisted({
|
||||
healthRetentionDays: 7,
|
||||
healthMaxEntries: 999,
|
||||
});
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('90'); // unchanged
|
||||
expect(process.env.HEALTH_MAX_ENTRIES).toBe('999'); // applied
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ envKey: 'HEALTH_HISTORY_RETENTION', reason: 'env-already-set' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects non-numeric values for numeric fields', () => {
|
||||
writePersisted({
|
||||
healthCheckInterval: 'fast', // not numeric
|
||||
healthMaxEntries: '500x', // not numeric
|
||||
healthRetentionDays: 14, // valid
|
||||
auditMaxEntries: null, // silently skipped (null)
|
||||
backupMaxStorageBytes: '', // silently skipped (empty)
|
||||
});
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
|
||||
// Only the valid value lands in `applied`.
|
||||
expect(result.applied.map((a) => a.envKey)).toEqual(['HEALTH_HISTORY_RETENTION']);
|
||||
// Non-numeric values appear in `skipped` with reason='non-numeric'.
|
||||
// null and '' are silently filtered (treated as "field not present").
|
||||
expect(result.skipped.map((s) => s.envKey).sort()).toEqual(
|
||||
['HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES'].sort(),
|
||||
);
|
||||
expect(result.skipped.every((s) => s.reason === 'non-numeric')).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces numeric strings (e.g. "14") to integer strings', () => {
|
||||
writePersisted({ healthRetentionDays: '14' });
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
||||
// Must be an integer-formatted string (not "14.7", "14x", etc.)
|
||||
expect(Number.isInteger(parseInt(process.env.HEALTH_HISTORY_RETENTION, 10))).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent across multiple calls (once-guard)', () => {
|
||||
writePersisted({ healthRetentionDays: 7 });
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const first = loader({ dataDir: TMP_DATA_DIR });
|
||||
const second = loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(first.applied).toHaveLength(1);
|
||||
expect(second.applied).toEqual([]);
|
||||
expect(second.alreadyRun).toBe(true);
|
||||
});
|
||||
|
||||
it('skips unknown fields without crashing', () => {
|
||||
writePersisted({
|
||||
healthRetentionDays: 14,
|
||||
unknownField: 'whatever',
|
||||
anotherUnknown: { nested: true },
|
||||
});
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
||||
});
|
||||
|
||||
it('returns a summary object with source path', () => {
|
||||
writePersisted({ healthRetentionDays: 14 });
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(result.source).toBe(path.join(TMP_DATA_DIR, 'disk-settings.json'));
|
||||
expect(result.alreadyRun).toBe(false);
|
||||
});
|
||||
|
||||
it('writes a boot summary to stderr when no logger is provided', () => {
|
||||
writePersisted({ healthRetentionDays: 14, healthMaxEntries: 999 });
|
||||
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
loader({ dataDir: TMP_DATA_DIR }); // no logger passed
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[disk-settings-loader\] rehydrated 2 setting/),
|
||||
);
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Smoke tests for the audit-log viewer route (DC-050).
|
||||
*
|
||||
* Mirrors the caddy-upstreams.routes.test.js pattern: build the router with
|
||||
* stubbed dependencies, hit it via a tiny express app, assert the response
|
||||
* shape and the audit-logger calls.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const FIXTURE_ENTRIES = [
|
||||
{
|
||||
id: 'a1', timestamp: '2026-08-17T10:00:00.000Z', ip: '1.1.1.1',
|
||||
action: 'service.create', resource: 'plex',
|
||||
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
||||
},
|
||||
{
|
||||
id: 'a2', timestamp: '2026-08-17T11:00:00.000Z', ip: '1.1.1.1',
|
||||
action: 'auth.totp-setup', resource: 'u-1',
|
||||
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
||||
},
|
||||
{
|
||||
id: 'a3', timestamp: '2026-08-17T12:00:00.000Z', ip: '2.2.2.2',
|
||||
action: 'auth.api-key-generate', resource: 'unknown',
|
||||
details: { userId: null }, outcome: 'failure',
|
||||
},
|
||||
{
|
||||
id: 'a4', timestamp: '2026-08-17T13:00:00.000Z', ip: '1.1.1.1',
|
||||
action: 'backup.execute', resource: 'all-apps',
|
||||
details: {}, outcome: 'success',
|
||||
},
|
||||
{
|
||||
id: 'a5', timestamp: '2026-08-17T14:00:00.000Z', ip: '3.3.3.3',
|
||||
action: 'caddy.add-site', resource: 'test.sami',
|
||||
details: {}, outcome: 'failure',
|
||||
},
|
||||
];
|
||||
|
||||
function buildFakeAuditLogger(entries = FIXTURE_ENTRIES) {
|
||||
return {
|
||||
query: jest.fn(async ({ limit = 50, offset = 0, action } = {}) => {
|
||||
let e = entries;
|
||||
if (action) e = e.filter((x) => x.action && x.action.startsWith(action));
|
||||
return e.slice(offset, offset + limit);
|
||||
}),
|
||||
clear: jest.fn(async () => {}),
|
||||
// log() is called by the DELETE handler to record `audit.clear` BEFORE
|
||||
// clearing — the act of clearing is itself an audit-worthy event.
|
||||
log: jest.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('routes/audit-log', () => {
|
||||
function buildRouter(logger) {
|
||||
const mod = require('../../routes/audit-log');
|
||||
return mod({
|
||||
asyncHandler: (fn) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
auditLogger: logger,
|
||||
});
|
||||
}
|
||||
|
||||
test('router builds with the expected paths', () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const router = buildRouter(logger);
|
||||
expect(router).toBeDefined();
|
||||
expect(typeof router.use).toBe('function');
|
||||
const paths = router.stack
|
||||
.filter((l) => l.route)
|
||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||
.flat();
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'GET /audit-logs',
|
||||
'GET /audit-logs/actions',
|
||||
'DELETE /audit-logs',
|
||||
]));
|
||||
});
|
||||
|
||||
test('GET /audit-logs returns all entries when no filters', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=10`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.entries).toHaveLength(5);
|
||||
expect(body.total).toBe(5);
|
||||
expect(body.hasMore).toBe(false);
|
||||
expect(body.filters).toEqual({ action: null, since: null, until: null, outcome: null });
|
||||
});
|
||||
|
||||
test('GET /audit-logs respects limit + offset', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=2&offset=0`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.entries).toHaveLength(2);
|
||||
expect(body.entries[0].id).toBe('a1');
|
||||
expect(body.hasMore).toBe(true);
|
||||
|
||||
const server2 = app.listen(0);
|
||||
const { port: port2 } = server2.address();
|
||||
const res2 = await fetch(`http://127.0.0.1:${port2}/audit-logs?limit=2&offset=4`);
|
||||
const body2 = await res2.json();
|
||||
server2.close();
|
||||
expect(body2.entries).toHaveLength(1);
|
||||
expect(body2.entries[0].id).toBe('a5');
|
||||
expect(body2.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
test('GET /audit-logs?action=auth filters server-side via auditLogger.query', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=auth`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.entries).toHaveLength(2);
|
||||
expect(body.entries.every((e) => e.action.startsWith('auth'))).toBe(true);
|
||||
// The action filter MUST be pushed down to the audit-logger so we don't
|
||||
// load the full 1000-entry store when the operator filters by category.
|
||||
expect(logger.query).toHaveBeenCalledWith(expect.objectContaining({ action: 'auth' }));
|
||||
});
|
||||
|
||||
test('GET /audit-logs rejects unknown action prefix with 400', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=pwnz`);
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/action must be one of/);
|
||||
});
|
||||
|
||||
test('GET /audit-logs filters by since (date >= since)', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T13:00:00.000Z`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.entries).toHaveLength(2); // a4 + a5
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['a4', 'a5']);
|
||||
});
|
||||
|
||||
test('GET /audit-logs filters by outcome=failure', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?outcome=failure`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.entries).toHaveLength(2); // a3 + a5
|
||||
expect(body.entries.every((e) => e.outcome === 'failure')).toBe(true);
|
||||
});
|
||||
|
||||
test('GET /audit-logs rejects since > until with 400', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T20:00:00Z&until=2026-08-17T10:00:00Z`);
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/since must be <= until/);
|
||||
});
|
||||
|
||||
test('GET /audit-logs rejects malformed ISO 8601 with 400', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=not-a-date`);
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/since must be ISO 8601/);
|
||||
});
|
||||
|
||||
test('GET /audit-logs caps limit at 500 (no DoS via huge page)', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=99999`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.limit).toBe(500);
|
||||
});
|
||||
|
||||
test('GET /audit-logs/actions returns distinct action prefixes', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.prefixes).toEqual(['auth', 'backup', 'caddy', 'service']);
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs requires confirm=CLEAR body', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/confirm: "CLEAR"/);
|
||||
expect(logger.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs with confirm=CLEAR calls auditLogger.clear()', async () => {
|
||||
const logger = buildFakeAuditFixtureSafe();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.cleared).toBe(true);
|
||||
expect(logger.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('module.exports throws when auditLogger is missing query()', () => {
|
||||
const mod = require('../../routes/audit-log');
|
||||
expect(() => mod({ asyncHandler: (fn) => fn, auditLogger: {} }))
|
||||
.toThrow(/auditLogger with query/);
|
||||
});
|
||||
|
||||
// ── GLM round-1 defect regressions ───────────────────────────────────────
|
||||
|
||||
test('GET /audit-logs does NOT amputate the store when limit*5 < MAX_ENTRIES (cap-truncation fix)', async () => {
|
||||
// Round-1 [HIGH]: route previously fetched `limit * 5` entries from
|
||||
// the store and computed total/hasMore over that truncated slice.
|
||||
// With MAX_ENTRIES=1000 and limit=50, the cap was 250 — silently
|
||||
// hiding entries 251-1000. The fix fetches the full store (1000).
|
||||
const entries = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `bulk-${i}`,
|
||||
timestamp: new Date(Date.parse('2026-08-17T00:00:00Z') + i * 1000).toISOString(),
|
||||
ip: '9.9.9.9',
|
||||
action: 'service.create',
|
||||
resource: `svc-${i}`,
|
||||
details: {},
|
||||
outcome: i % 3 === 0 ? 'failure' : 'success',
|
||||
}));
|
||||
const logger = buildFakeAuditLogger(entries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=50&offset=200`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.total).toBe(1000); // full store, not 250
|
||||
expect(body.hasMore).toBe(true); // still more after offset 200
|
||||
expect(body.truncated).toBe(true); // signal that store was at cap
|
||||
});
|
||||
|
||||
test('GET /audit-logs compares ISO timestamps numerically (lexicographic compare fix)', async () => {
|
||||
// Round-1 [MEDIUM]: '10:00:00.000Z' < '10:00:00Z' is false lexicographically
|
||||
// (the latter is a strict substring, breaking `>=`). Fix: use Date.parse().
|
||||
const fixedEntries = [
|
||||
{ id: 'b1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'b2', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
];
|
||||
const logger = buildFakeAuditLogger(fixedEntries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
// Same instant as b1 in a different ISO format — must be included.
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T10:00:00Z`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
test('GET /audit-logs accepts ISO with positive UTC offset (numeric compare fix)', async () => {
|
||||
const fixedEntries = [
|
||||
{ id: 'c1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'c2', timestamp: '2026-08-17T11:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'c3', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
];
|
||||
const logger = buildFakeAuditLogger(fixedEntries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
// 11:00+02:00 = 09:00Z. Filter for entries AFTER 09:00Z. Expect c1 + c2 + c3.
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T11:00:00%2B02:00`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.total).toBe(3);
|
||||
});
|
||||
|
||||
test('GET /audit-logs/actions only surfaces whitelisted prefixes', async () => {
|
||||
// Round-1 [LOW]: dropdown advertised prefixes (e.g. `logs`, `events`)
|
||||
// that GET /audit-logs?action=logs would then 400. Fix: intersect with
|
||||
// the whitelist before returning.
|
||||
const mixedEntries = [
|
||||
{ id: 'd1', timestamp: '2026-08-17T10:00:00Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'd2', timestamp: '2026-08-17T10:01:00Z', ip: '', action: 'logs.something', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'd3', timestamp: '2026-08-17T10:02:00Z', ip: '', action: 'events.publish', resource: '', details: {}, outcome: 'success' },
|
||||
];
|
||||
const logger = buildFakeAuditLogger(mixedEntries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.prefixes).toEqual(['service']); // logs/events filtered out
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs writes audit.clear BEFORE AND AFTER clear() — re-injection preserves the forensic breadcrumb', async () => {
|
||||
// GLM round-2 [MEDIUM]: a naive "log before clear()" self-erases —
|
||||
// clear() wipes the entry that was just written. Fix: log before
|
||||
// clear() (catches any failure path), then clear(), then log AGAIN
|
||||
// so the entry survives as the single row visible to the viewer.
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
// log() runs TWICE — once before clear (catches failure paths) and
|
||||
// once after clear (re-injects the forensic breadcrumb).
|
||||
expect(logger.log).toHaveBeenCalledTimes(2);
|
||||
expect(logger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
action: 'audit.clear',
|
||||
resource: 'audit-log.json',
|
||||
outcome: 'success',
|
||||
}));
|
||||
expect(logger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
action: 'audit.clear',
|
||||
resource: 'audit-log.json',
|
||||
outcome: 'success',
|
||||
}));
|
||||
// Ordering: log → clear → log (second log runs AFTER clear).
|
||||
const logOrders = logger.log.mock.invocationCallOrder;
|
||||
const clearOrder = logger.clear.mock.invocationCallOrder[0];
|
||||
expect(logOrders[0]).toBeLessThan(clearOrder);
|
||||
expect(logOrders[1]).toBeGreaterThan(clearOrder);
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs still calls clear() even if auditLogger.log() throws', async () => {
|
||||
// A failing audit-log write must NOT block the operator's clear.
|
||||
const logger = buildFakeAuditLogger();
|
||||
logger.log.mockRejectedValueOnce(new Error('disk full'));
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(logger.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Tiny helper — separated so the second clear test has a fresh mock.
|
||||
function buildFakeAuditFixtureSafe() {
|
||||
return buildFakeAuditLogger();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Smoke tests for the caddy-upstreams router.
|
||||
*
|
||||
* No jest.mock('fs') here — the route module needs a real express
|
||||
* context to load, and the watcher logic is tested separately in
|
||||
* caddy-upstream-watcher.test.js.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
|
||||
describe('routes/caddy-upstreams', () => {
|
||||
test('router builds with all expected paths and handlers', () => {
|
||||
const mod = require('../../routes/caddy-upstreams');
|
||||
const fakeWatcher = {
|
||||
snapshot: jest.fn(() => ({ upstreams: [], config: {} }))
|
||||
};
|
||||
const fakeHealthChecker = { incidents: [] };
|
||||
|
||||
const router = mod({
|
||||
asyncHandler: (fn) => fn,
|
||||
caddyUpstreamWatcher: fakeWatcher,
|
||||
healthChecker: fakeHealthChecker
|
||||
});
|
||||
|
||||
expect(router).toBeDefined();
|
||||
expect(typeof router.use).toBe('function');
|
||||
|
||||
const paths = router.stack
|
||||
.filter((l) => l.route)
|
||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||
.flat();
|
||||
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'GET /caddy/upstreams',
|
||||
'GET /caddy/upstreams/incidents',
|
||||
'POST /caddy/upstreams/mute',
|
||||
'POST /caddy/upstreams/:host/mute',
|
||||
'POST /caddy/upstreams/:host/unmute'
|
||||
]));
|
||||
});
|
||||
|
||||
test('GET /caddy/upstreams responds with watcher snapshot', async () => {
|
||||
const mod = require('../../routes/caddy-upstreams');
|
||||
const fakeSnapshot = { upstreams: [{ host: '1.1.1.1:80', status: 'up', muted: false }], config: {} };
|
||||
const fakeWatcher = { snapshot: jest.fn(() => fakeSnapshot) };
|
||||
const fakeHealthChecker = { incidents: [] };
|
||||
|
||||
// Build a tiny express app with the route + a shim success/error responder.
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.success = (data) => res.json({ success: true, ...data });
|
||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
||||
next();
|
||||
});
|
||||
app.use(mod({
|
||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
caddyUpstreamWatcher: fakeWatcher,
|
||||
healthChecker: fakeHealthChecker
|
||||
}));
|
||||
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.upstreams).toEqual(fakeSnapshot.upstreams);
|
||||
});
|
||||
|
||||
test('POST /caddy/upstreams/mute with body {host, muted:"false"} does NOT mute (string coercion)', async () => {
|
||||
// Regression: bare route previously used `muted !== false` which muted
|
||||
// when muted was a string 'false' (because 'false' !== false). Fix
|
||||
// requires explicit `muted === false` to unmute.
|
||||
const mod = require('../../routes/caddy-upstreams');
|
||||
const fakeSnapshot = { upstreams: [], config: {} };
|
||||
const fakeWatcher = {
|
||||
snapshot: jest.fn(() => fakeSnapshot),
|
||||
upstreams: new Map([['known:80', { host: 'known:80' }]]),
|
||||
setMuted: jest.fn(() => ({ host: 'known:80', muted: false }))
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.success = (data) => res.json({ success: true, ...data });
|
||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
||||
next();
|
||||
});
|
||||
app.use(mod({
|
||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
caddyUpstreamWatcher: fakeWatcher,
|
||||
healthChecker: { incidents: [] }
|
||||
}));
|
||||
// Error middleware MUST be registered AFTER routes so it actually catches.
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.statusCode === 400) {
|
||||
return res.status(400).json({ success: false, error: err.message });
|
||||
}
|
||||
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
|
||||
});
|
||||
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
|
||||
// String 'false' should NOT mute (should unmute or pass through)
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: 'known:80', muted: 'false' })
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(fakeWatcher.setMuted).toHaveBeenCalledWith('known:80', false);
|
||||
|
||||
// Unknown host should 400
|
||||
fakeWatcher.setMuted.mockClear();
|
||||
const res2 = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: 'not-a-real-host:80' })
|
||||
});
|
||||
const body2 = await res2.json();
|
||||
server.close();
|
||||
expect(res2.status).toBe(400);
|
||||
expect(body2.error).toMatch(/not a known upstream/);
|
||||
expect(fakeWatcher.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Generated
+270
-159
@@ -19,13 +19,13 @@
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lru-cache": "^10.4.3",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"pdfkit": "^0.15.2",
|
||||
"png-to-ico": "^2.1.8",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"sharp": "^0.33.5",
|
||||
"sharp": "^0.35.3",
|
||||
"ssh2-sftp-client": "^11.0.0",
|
||||
"validator": "^13.11.0",
|
||||
"webdav": "^5.7.1",
|
||||
@@ -564,9 +564,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -771,10 +771,19 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -784,19 +793,19 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -806,19 +815,38 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -832,9 +860,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -848,9 +876,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -864,9 +892,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -879,10 +907,42 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -896,9 +956,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -912,9 +972,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -928,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -944,9 +1004,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -956,19 +1016,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -978,19 +1038,63 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1000,19 +1104,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1022,19 +1126,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1044,19 +1148,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1066,38 +1170,73 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.2.0"
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1107,16 +1246,16 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1126,7 +1265,7 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
@@ -2623,19 +2762,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2654,16 +2780,6 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -6002,9 +6118,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -6127,9 +6243,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
||||
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -7242,48 +7358,58 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||
"hasInstallScript": true,
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
"detect-libc": "^2.0.3",
|
||||
"semver": "^7.6.3"
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.33.5",
|
||||
"@img/sharp-darwin-x64": "0.33.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5",
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
||||
"@img/sharp-linux-arm": "0.33.5",
|
||||
"@img/sharp-linux-arm64": "0.33.5",
|
||||
"@img/sharp-linux-s390x": "0.33.5",
|
||||
"@img/sharp-linux-x64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||
"@img/sharp-wasm32": "0.33.5",
|
||||
"@img/sharp-win32-ia32": "0.33.5",
|
||||
"@img/sharp-win32-x64": "0.33.5"
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -7393,21 +7519,6 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle/node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lru-cache": "^10.4.3",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"pdfkit": "^0.15.2",
|
||||
"png-to-ico": "^2.1.8",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"sharp": "^0.33.5",
|
||||
"sharp": "^0.35.3",
|
||||
"ssh2-sftp-client": "^11.0.0",
|
||||
"validator": "^13.11.0",
|
||||
"webdav": "^5.7.1",
|
||||
|
||||
@@ -95,7 +95,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
|
||||
log.info('deploy', 'DashCA: Static site deployment completed successfully');
|
||||
} catch (error) {
|
||||
log.error('deploy', 'DashCA deployment error', { error: error.message });
|
||||
log.error('deploy', error, null, { note: 'DashCA deployment error' });
|
||||
throw new Error(`DashCA deployment failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
await portLockManager.releasePorts(lockId);
|
||||
log.info('deploy', 'Port locks released after error', { lockId });
|
||||
} catch (releaseError) {
|
||||
log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message });
|
||||
log.error('deploy', releaseError, null, { note: 'Failed to release port locks', lockId });
|
||||
}
|
||||
}
|
||||
throw deployError;
|
||||
@@ -425,7 +425,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
} catch (error) {
|
||||
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||
const msg = error?.message || String(error || 'Unknown error');
|
||||
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||
log.error('deploy', error, null, { note: 'Deployment failed', appId });
|
||||
const template = ctx.APP_TEMPLATES[appId];
|
||||
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||
|
||||
@@ -297,7 +297,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
||||
// P0-5 fix: was `errorResponse(res, 500, err.message)` which leaks internal
|
||||
// error details (paths, stack traces, library error codes) to the client.
|
||||
// Log the actual error server-side and return a generic message.
|
||||
log.error('apps-revert', 'Revert failed', { error: err.message, stack: err.stack });
|
||||
log.error('apps-revert', err, null, { note: 'Revert failed', stack: err.stack });
|
||||
errorResponse(res, 500, 'Revert failed');
|
||||
}
|
||||
}, 'apps-revert'));
|
||||
|
||||
@@ -148,7 +148,7 @@ module.exports = function({
|
||||
}
|
||||
} catch (error) {
|
||||
results.caddy = `failed: ${error.message}`;
|
||||
log.error('caddy', 'Caddy update error', { error: error.message });
|
||||
log.error('caddy', error, null, { note: 'Caddy update error' });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
||||
stream.on('error', () => resolve(null));
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('docker', 'Failed to get API key', { containerName, error: error.message });
|
||||
log.error('docker', error, null, { note: 'Failed to get API key', containerName });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
||||
stream.on('error', () => resolve(null));
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('docker', 'Failed to get Plex token', { error: error.message });
|
||||
log.error('docker', error, null, { note: 'Failed to get Plex token' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
||||
const sessionCookie = setCookie.split(';')[0];
|
||||
return { cookie: sessionCookie, plexToken };
|
||||
} catch (e) {
|
||||
log.error('arr', 'Could not get Seerr session', { error: e.message });
|
||||
log.error('arr', e, null, { note: 'Could not get Seerr session' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Audit log viewer routes
|
||||
*
|
||||
* Exposes:
|
||||
* GET /api/v1/audit-logs — paginated audit entries (auth-gated)
|
||||
* GET /api/v1/audit-logs/actions — distinct action prefixes (for filter dropdowns)
|
||||
* DELETE /api/v1/audit-logs — clear the audit log (admin-gated)
|
||||
*
|
||||
* The frontend at status/js/audit-log.js already calls /api/v1/audit-logs
|
||||
* with {limit, offset, action=<prefix>}. Before this route existed the
|
||||
* frontend silently 404'd (see STATE.md Queue item #1, DC-050).
|
||||
*
|
||||
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
||||
* (the router is mounted under the auth-gated apiRouter in app.js).
|
||||
*
|
||||
* @module routes/audit-log
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, errorResponse } = require('../src/utils/responses');
|
||||
|
||||
// Action prefixes that the dashboard's filter dropdown offers + that the
|
||||
// `action` query parameter will accept. Curated, NOT derived from current
|
||||
// log contents — see /audit-logs/actions for the live set.
|
||||
const ACTION_PREFIX_WHITELIST = [
|
||||
'service', 'container', 'caddy', 'dns', 'backup', 'config',
|
||||
'auth', 'totp', 'update', 'monitoring', 'site', 'arr', 'tailscale',
|
||||
];
|
||||
|
||||
const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
|
||||
|
||||
function parseInt10(value, fallback) {
|
||||
const n = parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function isValidActionPrefix(value) {
|
||||
return ACTION_PREFIX_WHITELIST.includes(value);
|
||||
}
|
||||
|
||||
function isValidIso(value) {
|
||||
if (typeof value !== 'string' || value.length < 10) return false;
|
||||
return ISO8601_RE.test(value);
|
||||
}
|
||||
|
||||
// Parse an ISO 8601 string into ms-since-epoch. Returns NaN for invalid
|
||||
// input — callers must pre-validate with isValidIso(). Used to compare
|
||||
// timestamps numerically (lexicographic compare breaks when the two
|
||||
// strings use different offset formats).
|
||||
function toEpochMs(iso) {
|
||||
const ms = Date.parse(iso);
|
||||
return ms;
|
||||
}
|
||||
|
||||
module.exports = function({ asyncHandler, auditLogger }) {
|
||||
if (!auditLogger || typeof auditLogger.query !== 'function') {
|
||||
throw new Error('audit-log route requires auditLogger with query()');
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /audit-logs?limit=50&offset=0&action=<prefix>&since=<iso>&until=<iso>&outcome=<success|failure>
|
||||
router.get('/audit-logs', asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(Math.max(parseInt10(req.query.limit, 50), 1), 500);
|
||||
const offset = Math.max(parseInt10(req.query.offset, 0), 0);
|
||||
const actionPrefix = typeof req.query.action === 'string' && req.query.action.length > 0
|
||||
? req.query.action
|
||||
: null;
|
||||
const sinceRaw = typeof req.query.since === 'string' && req.query.since.length > 0
|
||||
? req.query.since
|
||||
: null;
|
||||
const untilRaw = typeof req.query.until === 'string' && req.query.until.length > 0
|
||||
? req.query.until
|
||||
: null;
|
||||
const outcome = typeof req.query.outcome === 'string' && req.query.outcome.length > 0
|
||||
? req.query.outcome
|
||||
: null;
|
||||
|
||||
if (actionPrefix !== null && !isValidActionPrefix(actionPrefix)) {
|
||||
return errorResponse(res, 400,
|
||||
`action must be one of: ${ACTION_PREFIX_WHITELIST.join(', ')}`);
|
||||
}
|
||||
if (sinceRaw !== null && !isValidIso(sinceRaw)) {
|
||||
return errorResponse(res, 400, 'since must be ISO 8601 (e.g. 2026-08-17T00:00:00Z)');
|
||||
}
|
||||
if (untilRaw !== null && !isValidIso(untilRaw)) {
|
||||
return errorResponse(res, 400, 'until must be ISO 8601 (e.g. 2026-08-18T00:00:00Z)');
|
||||
}
|
||||
if (outcome !== null && !['success', 'failure', 'unknown'].includes(outcome)) {
|
||||
return errorResponse(res, 400, 'outcome must be one of: success, failure, unknown');
|
||||
}
|
||||
const sinceMs = sinceRaw !== null ? toEpochMs(sinceRaw) : null;
|
||||
const untilMs = untilRaw !== null ? toEpochMs(untilRaw) : null;
|
||||
if (sinceMs !== null && untilMs !== null && sinceMs > untilMs) {
|
||||
return errorResponse(res, 400, 'since must be <= until');
|
||||
}
|
||||
|
||||
// Pull the FULL store (capped at MAX_ENTRIES by audit-logger) so
|
||||
// date + outcome filters see the whole log, not the newest-N-only slice.
|
||||
// The store is bounded by design; a 1000-entry in-memory filter pass is
|
||||
// cheap (~tens of ms) and correct. Read the env-tunable MAX_ENTRIES so
|
||||
// operators who raise AUDIT_MAX_ENTRIES get correct filter coverage.
|
||||
const MAX_AUDIT_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
||||
const allEntries = await auditLogger.query({
|
||||
limit: MAX_AUDIT_ENTRIES,
|
||||
offset: 0,
|
||||
action: actionPrefix || undefined,
|
||||
});
|
||||
|
||||
let filtered = allEntries;
|
||||
if (sinceMs !== null) {
|
||||
filtered = filtered.filter((e) => {
|
||||
const t = toEpochMs(e.timestamp);
|
||||
return Number.isFinite(t) && t >= sinceMs;
|
||||
});
|
||||
}
|
||||
if (untilMs !== null) {
|
||||
filtered = filtered.filter((e) => {
|
||||
const t = toEpochMs(e.timestamp);
|
||||
return Number.isFinite(t) && t <= untilMs;
|
||||
});
|
||||
}
|
||||
if (outcome !== null) {
|
||||
filtered = filtered.filter((e) => (e.outcome || 'unknown') === outcome);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const page = filtered.slice(offset, offset + limit);
|
||||
|
||||
return success(res, {
|
||||
entries: page,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
// truncated: true tells the caller the total is bounded by the
|
||||
// store's MAX_AUDIT_ENTRIES — the operator can see the whole log
|
||||
// but if more entries have been written since the last clear,
|
||||
// older rows are dropped at write-time, not at read-time.
|
||||
truncated: allEntries.length >= MAX_AUDIT_ENTRIES,
|
||||
hasMore: offset + page.length < total,
|
||||
filters: { action: actionPrefix, since: sinceRaw, until: untilRaw, outcome },
|
||||
});
|
||||
}, 'audit-logs-list'));
|
||||
|
||||
// GET /audit-logs/actions — return the distinct action prefixes present
|
||||
// in the current log, INTERSECTED with the whitelist so the dropdown
|
||||
// only offers prefixes the GET /audit-logs filter will actually accept.
|
||||
router.get('/audit-logs/actions', asyncHandler(async (req, res) => {
|
||||
const maxAudit = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
||||
const entries = await auditLogger.query({ limit: maxAudit, offset: 0 });
|
||||
const seen = new Set();
|
||||
for (const e of entries) {
|
||||
if (!e.action) continue;
|
||||
const dot = e.action.indexOf('.');
|
||||
const prefix = dot > 0 ? e.action.slice(0, dot) : e.action;
|
||||
// Only surface prefixes that are also in the whitelist — otherwise
|
||||
// the dropdown would offer a prefix that GET /audit-logs would 400.
|
||||
if (ACTION_PREFIX_WHITELIST.includes(prefix)) seen.add(prefix);
|
||||
}
|
||||
const prefixes = Array.from(seen).sort();
|
||||
return success(res, { prefixes });
|
||||
}, 'audit-logs-actions'));
|
||||
|
||||
// DELETE /audit-logs — clear the audit log. The frontend's "Clear Log"
|
||||
// button already calls DELETE /api/v1/audit-logs (status/js/audit-log.js).
|
||||
// Body must include { confirm: 'CLEAR' } as an opt-in guard against
|
||||
// accidental destructive calls.
|
||||
//
|
||||
// Forensic integrity: clear() wipes audit-log.json to []. A naive
|
||||
// "log audit.clear before clear()" leaves zero trace because clear()
|
||||
// runs after — the new entry is wiped with the rest. Fix: write the
|
||||
// audit.clear entry FIRST so it's in the buffer, then clear() the
|
||||
// store, then RE-INJECT the audit.clear entry as the single surviving
|
||||
// row. The viewer shows "1 entry: audit.clear by <user> at <ts>" — a
|
||||
// visible forensic breadcrumb that the log was just wiped.
|
||||
router.delete('/audit-logs', asyncHandler(async (req, res) => {
|
||||
const confirm = req.body?.confirm;
|
||||
if (confirm !== 'CLEAR') {
|
||||
return errorResponse(res, 400,
|
||||
'destructive op: pass { confirm: "CLEAR" } in JSON body');
|
||||
}
|
||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
||||
const userAttrs = (req.user && req.user.id) ? {
|
||||
userId: req.user.id,
|
||||
userRole: req.user.role || null,
|
||||
userEmail: req.user.email || null,
|
||||
} : {};
|
||||
const clearEntry = {
|
||||
action: 'audit.clear',
|
||||
resource: 'audit-log.json',
|
||||
outcome: 'success',
|
||||
ip,
|
||||
details: {
|
||||
confirmedBy: req.body?.confirmedBy || 'dashboard',
|
||||
...userAttrs,
|
||||
},
|
||||
};
|
||||
// Write the clear entry FIRST so it lands at index 0 of the buffer.
|
||||
// Failure is non-fatal — the operator still wants the log cleared.
|
||||
try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
|
||||
// Now wipe the store. The just-written audit.clear entry is wiped too.
|
||||
await auditLogger.clear();
|
||||
// Re-inject the audit.clear entry so the forensic breadcrumb survives.
|
||||
// This is the difference between "log wiped, zero trace" and
|
||||
// "log wiped, viewer shows one entry: audit.clear by X at T".
|
||||
try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
|
||||
return success(res, { cleared: true });
|
||||
}, 'audit-logs-clear'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Caddy upstreams routes
|
||||
*
|
||||
* Exposes:
|
||||
* GET /api/v1/caddy/upstreams — full snapshot
|
||||
* GET /api/v1/caddy/upstreams/incidents — open dead-upstream incidents (via healthChecker)
|
||||
* POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } (also via query ?muted=true)
|
||||
*
|
||||
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
||||
* (the router is mounted under the auth-gated apiRouter in app.js).
|
||||
*
|
||||
* @module routes/caddy-upstreams
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/caddy/upstreams', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
success(res, caddyUpstreamWatcher.snapshot());
|
||||
}, 'caddy-upstreams-list'));
|
||||
|
||||
router.get('/caddy/upstreams/incidents', asyncHandler(async (req, res) => {
|
||||
if (!healthChecker) {
|
||||
return success(res, { incidents: [] });
|
||||
}
|
||||
// Filter the in-memory incidents array to caddy-upstream-dead entries.
|
||||
const all = Array.isArray(healthChecker.incidents) ? healthChecker.incidents : [];
|
||||
const open = all
|
||||
.filter((i) => i && i.type === 'caddy-upstream-dead' && i.status === 'open')
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
serviceId: i.serviceId,
|
||||
type: i.type,
|
||||
message: i.message,
|
||||
severity: i.severity,
|
||||
createdAt: i.createdAt,
|
||||
lastOccurrence: i.lastOccurrence,
|
||||
occurrences: i.occurrences,
|
||||
details: i.details
|
||||
}));
|
||||
success(res, { incidents: open });
|
||||
}, 'caddy-upstreams-incidents'));
|
||||
|
||||
// POST /caddy/upstreams/mute body { host, muted }
|
||||
// POST /caddy/upstreams/:host/mute body { muted: true } OR query ?muted=true
|
||||
// Both shapes supported because the dashboard code is small and either is
|
||||
// ergonomic depending on caller.
|
||||
const handleMute = asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
const host = req.params.host || req.body?.host;
|
||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
// Accept muted as boolean body field OR ?muted=true|false query OR
|
||||
// a { muted: true|false } JSON body. Default to toggling on bare POST
|
||||
// without a muted value (this is the "mute it" path).
|
||||
let muted;
|
||||
if (typeof req.body?.muted === 'boolean') muted = req.body.muted;
|
||||
else if (typeof req.query.muted === 'string') muted = req.query.muted === 'true';
|
||||
else muted = true; // POST with no body = mute
|
||||
|
||||
const result = caddyUpstreamWatcher.setMuted(host, muted);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-mute');
|
||||
|
||||
// Bare /mute with JSON body {host, muted}. Default mutes when muted is
|
||||
// absent or unparseable; require muted === false explicitly to unmute.
|
||||
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
const { host, muted } = req.body || {};
|
||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
// Explicit boolean coercion — string 'false' should NOT mute.
|
||||
const wantMuted = muted === undefined ? true : muted === true;
|
||||
if (caddyUpstreamWatcher.upstreams && !caddyUpstreamWatcher.upstreams.has(host)) {
|
||||
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
|
||||
}
|
||||
const result = caddyUpstreamWatcher.setMuted(host, wantMuted);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-mute-bare'));
|
||||
|
||||
// /:host/mute and /:host/unmute for path-style toggles
|
||||
router.post('/caddy/upstreams/:host/mute', handleMute);
|
||||
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
const host = req.params.host;
|
||||
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
const result = caddyUpstreamWatcher.setMuted(host, false);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-unmute'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -162,7 +162,7 @@ module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
|
||||
await newContainer.start();
|
||||
} catch (startError) {
|
||||
// Clean up the failed container so it doesn't block future attempts
|
||||
log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
|
||||
log.error('docker', startError, null, { note: 'Failed to start new container', containerName });
|
||||
if (newContainer) {
|
||||
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
// DC-048 — the canonical disk-settings.json path. Shared by GET + POST.
|
||||
function getSettingsFile() {
|
||||
return path.join(platformPaths.dataDir, 'disk-settings.json');
|
||||
}
|
||||
|
||||
// GET current disk settings + actual disk usage
|
||||
router.get('/', (req, res) => {
|
||||
@@ -9,7 +15,10 @@ router.get('/', (req, res) => {
|
||||
const settings = {
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000'),
|
||||
healthMaxEntries: parseInt(process.env.HEALTH_MAX_ENTRIES || '500'),
|
||||
healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '14'),
|
||||
// DC-048 — align route default to engine default (health-checker.js:34
|
||||
// reads 30 from env when unset; the route previously showed 14 as the
|
||||
// "no override" value, which silently disagreed with the engine).
|
||||
healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '30'),
|
||||
statsMaxEntries: parseInt(process.env.CONTAINER_STATS_MAX_ENTRIES || '500'),
|
||||
auditMaxEntries: parseInt(process.env.AUDIT_MAX_ENTRIES || '1000'),
|
||||
backupMaxStorageBytes: parseInt(process.env.BACKUP_MAX_STORAGE_BYTES || '0'),
|
||||
@@ -31,7 +40,7 @@ router.get('/', (req, res) => {
|
||||
} catch {}
|
||||
|
||||
// Load persisted settings
|
||||
const settingsFile = path.join(path.dirname(require('../config/paths').configFile), 'disk-settings.json');
|
||||
const settingsFile = getSettingsFile();
|
||||
let persisted = {};
|
||||
try { persisted = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
||||
|
||||
@@ -45,24 +54,37 @@ router.get('/', (req, res) => {
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { healthInterval, healthMaxEntries, healthRetentionDays, statsMaxEntries, auditMaxEntries } = req.body;
|
||||
const updates = {};
|
||||
|
||||
if (healthInterval !== undefined) { updates.healthCheckInterval = parseInt(healthInterval); process.env.HEALTH_CHECK_INTERVAL = String(healthInterval); }
|
||||
if (healthMaxEntries !== undefined) { updates.healthMaxEntries = parseInt(healthMaxEntries); process.env.HEALTH_MAX_ENTRIES = String(healthMaxEntries); }
|
||||
if (healthRetentionDays !== undefined) { updates.healthRetentionDays = parseInt(healthRetentionDays); process.env.HEALTH_HISTORY_RETENTION = String(healthRetentionDays); }
|
||||
if (statsMaxEntries !== undefined) { updates.statsMaxEntries = parseInt(statsMaxEntries); process.env.CONTAINER_STATS_MAX_ENTRIES = String(statsMaxEntries); }
|
||||
if (auditMaxEntries !== undefined) { updates.auditMaxEntries = parseInt(auditMaxEntries); process.env.AUDIT_MAX_ENTRIES = String(auditMaxEntries); }
|
||||
// DC-048 — coerce + validate EVERY numeric input before persisting.
|
||||
// Without this gate, parseInt('abc') === NaN → String(NaN) === 'NaN' →
|
||||
// process.env.HEALTH_CHECK_INTERVAL becomes 'NaN' at runtime AND the
|
||||
// persisted file gets JSON.stringify({x: NaN}) === {"x": null} which
|
||||
// the loader silently drops on next boot. Validation now rejects the
|
||||
// request with 400 BEFORE any env mutation or file write.
|
||||
const intField = (name, value) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
||||
throw new Error(`${name} must be an integer (received ${JSON.stringify(value)})`);
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const updates = {};
|
||||
if (healthInterval !== undefined) { const n = intField('healthInterval', healthInterval); updates.healthCheckInterval = n; process.env.HEALTH_CHECK_INTERVAL = String(n); }
|
||||
if (healthMaxEntries !== undefined) { const n = intField('healthMaxEntries', healthMaxEntries); updates.healthMaxEntries = n; process.env.HEALTH_MAX_ENTRIES = String(n); }
|
||||
if (healthRetentionDays !== undefined) { const n = intField('healthRetentionDays', healthRetentionDays); updates.healthRetentionDays = n; process.env.HEALTH_HISTORY_RETENTION = String(n); }
|
||||
if (statsMaxEntries !== undefined) { const n = intField('statsMaxEntries', statsMaxEntries); updates.statsMaxEntries = n; process.env.CONTAINER_STATS_MAX_ENTRIES = String(n); }
|
||||
if (auditMaxEntries !== undefined) { const n = intField('auditMaxEntries', auditMaxEntries); updates.auditMaxEntries = n; process.env.AUDIT_MAX_ENTRIES = String(n); }
|
||||
|
||||
// Persist to file
|
||||
const paths = require('../config/paths');
|
||||
const settingsFile = path.join(path.dirname(paths.configFile), 'disk-settings.json');
|
||||
const settingsFile = getSettingsFile();
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({ ...existing, ...updates }, null, 2));
|
||||
|
||||
res.json({ success: true, updated: updates, message: 'Settings saved. Some changes apply on next container restart.' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
res.status(e.statusCode || 400).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ module.exports = function({
|
||||
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS record creation error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'Universal DNS record creation error' });
|
||||
errorResponse(res, safeErrorMessage(error), 500);
|
||||
}
|
||||
}, 'dns-universal-create'));
|
||||
@@ -136,7 +136,7 @@ module.exports = function({
|
||||
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS record deletion error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'Universal DNS record deletion error' });
|
||||
errorResponse(res, safeErrorMessage(error), 500);
|
||||
}
|
||||
}, 'dns-universal-delete'));
|
||||
@@ -167,7 +167,7 @@ module.exports = function({
|
||||
throw new NotFoundError('No records found for domain');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS resolve error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'Universal DNS resolve error' });
|
||||
errorResponse(res, safeErrorMessage(error), error.statusCode || 500);
|
||||
}
|
||||
}, 'dns-universal-resolve'));
|
||||
@@ -283,7 +283,7 @@ module.exports = function({
|
||||
// Error handled by middleware
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS record creation error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS record creation error' });
|
||||
errorResponse(res, safeErrorMessage(error), 500, { details: error.cause?.code || 'fetch failed' });
|
||||
}
|
||||
}, 'dns-create-record'));
|
||||
@@ -328,7 +328,7 @@ module.exports = function({
|
||||
// Error handled by middleware
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS resolve error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS resolve error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-resolve'));
|
||||
@@ -465,7 +465,7 @@ module.exports = function({
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS logs proxy error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS logs proxy error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-logs'));
|
||||
@@ -723,7 +723,7 @@ module.exports = function({
|
||||
// Error handled by middleware
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS update check error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS update check error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-check-update'));
|
||||
@@ -791,7 +791,7 @@ module.exports = function({
|
||||
manualUpdateRequired: true
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS update error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS update error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-update'));
|
||||
|
||||
@@ -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
|
||||
* <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
|
||||
@@ -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]
|
||||
};
|
||||
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);
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
success(res, { logs: logs.slice(-50).reverse() });
|
||||
// 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;
|
||||
@@ -165,7 +165,7 @@ async function handleExec(ws, containerId, log, auth) {
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
log.error('exec', 'Failed to start exec session', { containerId, error: err.message });
|
||||
log.error('exec', err, null, { note: 'Failed to start exec session', containerId });
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
ws.close();
|
||||
|
||||
@@ -149,7 +149,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
||||
|
||||
ok(res, response);
|
||||
} catch (error) {
|
||||
log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message });
|
||||
log.error('recipe', error, null, { note: 'Recipe deployment failed', recipeId });
|
||||
|
||||
// Cleanup: remove partially deployed containers
|
||||
for (const deployed of deployedComponents) {
|
||||
|
||||
@@ -421,7 +421,7 @@ module.exports = function({
|
||||
resyncHealthChecker?.().catch(() => {});
|
||||
success(res, { message: `Service "${name}" added to dashboard` });
|
||||
} catch (error) {
|
||||
log.error('deploy', 'Error adding service', { error: error.message });
|
||||
log.error('deploy', error, null, { note: 'Error adding service' });
|
||||
if (error.message.includes('already exists')) {
|
||||
errorResponse(res, safeErrorMessage(error), 409);
|
||||
} else {
|
||||
|
||||
@@ -46,7 +46,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log.error('caddy', 'Caddy reload failed', { error: errorText });
|
||||
log.error('caddy', new Error(`Caddy reload failed: ${errorText.slice(0, 500)}`));
|
||||
throw new Error('Caddy reload failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ module.exports = function({ asyncHandler, log }) {
|
||||
themes[slug] = data;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('themes', 'Failed to read themes', { error: e.message });
|
||||
log.error('themes', e, null, { note: 'Failed to read themes' });
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
+25
-26
@@ -68,30 +68,29 @@ process.on('uncaughtException', (error) => {
|
||||
attachExecWS(server, log, authManager);
|
||||
log.info('server', 'WebSocket exec handler attached (auth enforced)');
|
||||
|
||||
// DC-076: Attach dashboard WebSocket for real-time updates
|
||||
// DC-076: Attach dashboard WebSocket for real-time updates.
|
||||
// createApp() returns the live manager instances — use those instead
|
||||
// of re-requiring the modules (which yields singletons for some
|
||||
// managers and raw classes / namespace objects for others; calling
|
||||
// .on() on a class threw on every boot and silently killed the WS).
|
||||
try {
|
||||
const { ctx } = app.locals;
|
||||
const createDashboardWS = require('./src/websocket/dashboard-ws');
|
||||
const resourceMonitor = require('./src/managers/resource-monitor');
|
||||
const healthChecker = require('./src/monitoring/health-checker');
|
||||
const updateManager = require('./src/managers/update-manager');
|
||||
const dependencyManager = require('./src/managers/dependency-manager');
|
||||
const autoRestartManager = require('./src/managers/auto-restart-manager');
|
||||
const configDriftDetector = require('./src/managers/config-drift-detector');
|
||||
const sslMonitor = require('./src/monitoring/ssl-monitor');
|
||||
|
||||
createDashboardWS(server, {
|
||||
resourceMonitor,
|
||||
healthChecker,
|
||||
updateManager,
|
||||
dependencyManager,
|
||||
autoRestartManager,
|
||||
driftDetector: configDriftDetector,
|
||||
sslMonitor,
|
||||
resourceMonitor: ctx.resourceMonitor,
|
||||
healthChecker: ctx.healthChecker,
|
||||
updateManager: ctx.updateManager,
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
autoRestartManager: ctx.autoRestartManager,
|
||||
driftDetector: ctx.driftDetector,
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker,
|
||||
log,
|
||||
});
|
||||
log.info('server', 'Dashboard WebSocket attached at /api/v1/ws');
|
||||
} catch (err) {
|
||||
log.error('server', 'Dashboard WebSocket failed to attach', { error: err.message });
|
||||
log.error('server', err, null, { feature: 'dashboard-ws' });
|
||||
}
|
||||
|
||||
// Start feature modules
|
||||
@@ -136,7 +135,7 @@ process.on('uncaughtException', (error) => {
|
||||
workflowEngine = new WorkflowEngine(workflowCtx);
|
||||
log.info('server', 'Workflow engine initialized');
|
||||
} catch (err) {
|
||||
log.error('server', 'Workflow engine failed to initialize', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Workflow engine failed to initialize' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +144,7 @@ process.on('uncaughtException', (error) => {
|
||||
// Clean up stale port locks
|
||||
portLockManager.cleanupStaleLocks()
|
||||
.then(() => log.info('server', 'Port lock cleanup completed'))
|
||||
.catch(err => log.error('server', 'Port lock cleanup failed', { error: err.message }));
|
||||
.catch(err => log.error('server', err, null, { note: 'Port lock cleanup failed' }));
|
||||
|
||||
// Resource monitoring
|
||||
try {
|
||||
@@ -156,7 +155,7 @@ process.on('uncaughtException', (error) => {
|
||||
}
|
||||
log.info('server', 'Resource monitoring started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Resource monitoring failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Resource monitoring failed to start' });
|
||||
}
|
||||
|
||||
// Backup manager
|
||||
@@ -164,7 +163,7 @@ process.on('uncaughtException', (error) => {
|
||||
backupManager.start();
|
||||
log.info('server', 'Backup manager started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Backup manager failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Backup manager failed to start' });
|
||||
}
|
||||
|
||||
// Security event workers (Caddy access log, fail2ban, shared_bans)
|
||||
@@ -175,7 +174,7 @@ process.on('uncaughtException', (error) => {
|
||||
startSecurityWorkers({ log });
|
||||
log.info('server', 'Security event workers started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Security event workers failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Security event workers failed to start' });
|
||||
}
|
||||
|
||||
// Connect workflow engine to update manager for pre-update events
|
||||
@@ -206,7 +205,7 @@ process.on('uncaughtException', (error) => {
|
||||
healthChecker.start();
|
||||
log.info('server', 'Health checker started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Health checker failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Health checker failed to start' });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -215,7 +214,7 @@ process.on('uncaughtException', (error) => {
|
||||
updateManager.start();
|
||||
log.info('server', 'Update manager started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Update manager failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Update manager failed to start' });
|
||||
}
|
||||
|
||||
// Self-updater
|
||||
@@ -234,7 +233,7 @@ process.on('uncaughtException', (error) => {
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
log.error('server', 'Self-updater failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Self-updater failed to start' });
|
||||
}
|
||||
|
||||
// Docker maintenance (optional)
|
||||
@@ -257,7 +256,7 @@ process.on('uncaughtException', (error) => {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('server', 'Docker maintenance failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Docker maintenance failed to start' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +270,7 @@ process.on('uncaughtException', (error) => {
|
||||
log.info('digest', `Daily digest generated for ${date}`);
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('server', 'Log digest failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Log digest failed to start' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ const { asyncHandler } = require('./utils/async-handler');
|
||||
// Managers and utilities
|
||||
const StateManager = require('./managers/state-manager');
|
||||
const platformPaths = require('../platform-paths');
|
||||
// DC-048 — rehydrate process.env from disk-settings.json BEFORE any engine
|
||||
// module reads env at module-load time. Must run before health-checker,
|
||||
// audit-logger, and the backups route module (backups.js reads
|
||||
// BACKUP_MAX_STORAGE_BYTES at module load too).
|
||||
require('./config/disk-settings-loader')();
|
||||
const { LicenseManager } = require('./managers/license-manager');
|
||||
const credentialManager = require('./managers/credential-manager');
|
||||
const authManager = require('./managers/auth-manager');
|
||||
@@ -97,7 +102,9 @@ const securityRoutes = require('../routes/security');
|
||||
const diskSettingsRoutes = require('../routes/disk-settings');
|
||||
const aiIntentRoutes = require('../routes/ai-intent');
|
||||
const logInsightsRoutes = require('../routes/log-insights');
|
||||
const auditLogRoutes = require('../routes/audit-log');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const caddyUpstreamRoutes = require('../routes/caddy-upstreams');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
@@ -107,6 +114,7 @@ const { AutoRestartManager } = require('./managers/auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('./managers/config-drift-detector');
|
||||
const SSLMonitor = require('./monitoring/ssl-monitor');
|
||||
const { DiskSpaceMonitor } = require('./monitoring/disk-space-monitor');
|
||||
const caddyUpstreamWatcher = require('./monitoring/caddy-upstream-watcher');
|
||||
const DNSPropagationChecker = require('./dns/dns-propagation');
|
||||
|
||||
// Constants
|
||||
@@ -318,7 +326,7 @@ async function createApp() {
|
||||
const { writeJsonFile } = require('./utilities/fs-helpers');
|
||||
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
|
||||
} catch (e) {
|
||||
log.error('config', 'Could not save TOTP config', { error: e.message });
|
||||
log.error('config', e, null, { note: 'Could not save TOTP config' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +445,7 @@ async function createApp() {
|
||||
ctx.workflowEngine = workflowEngine;
|
||||
log.info('app', 'Workflow engine initialized');
|
||||
} catch (err) {
|
||||
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
|
||||
log.error('app', err, null, { note: 'Failed to initialize workflow engine' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +483,15 @@ async function createApp() {
|
||||
diskSpaceMonitor.start(600000); // 10 min
|
||||
log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB });
|
||||
|
||||
// Initialize caddy upstream watcher — independent probes of every
|
||||
// reverse_proxy directive in /etc/caddy/sites/, emits 'dead' incidents
|
||||
// after 5min of consecutive failures (so a single blip doesn't page).
|
||||
caddyUpstreamWatcher.log = log;
|
||||
caddyUpstreamWatcher.healthChecker = healthChecker;
|
||||
caddyUpstreamWatcher.start();
|
||||
ctx.caddyUpstreamWatcher = caddyUpstreamWatcher;
|
||||
log.info('app', 'Caddy upstream watcher initialized');
|
||||
|
||||
// Initialize DNS propagation checker
|
||||
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
|
||||
ctx.dnsPropagationChecker = dnsPropagationChecker;
|
||||
@@ -501,12 +518,12 @@ async function createApp() {
|
||||
if (ctx.notification && ctx.resourceMonitor) {
|
||||
ctx.resourceMonitor.on('alert', (alertData) => {
|
||||
ctx.notification.sendAlert(alertData).catch(err => {
|
||||
log.error('notification', 'Failed to send alert', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send alert' });
|
||||
});
|
||||
});
|
||||
ctx.resourceMonitor.on('auto-restart', (data) => {
|
||||
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
|
||||
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send auto-restart notification' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -514,12 +531,12 @@ async function createApp() {
|
||||
if (ctx.notification && ctx.backupManager) {
|
||||
ctx.backupManager.on('backup-complete', (data) => {
|
||||
ctx.notification.send('backup-complete', data).catch(err => {
|
||||
log.error('notification', 'Failed to send backup-complete', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send backup-complete' });
|
||||
});
|
||||
});
|
||||
ctx.backupManager.on('backup-failed', (data) => {
|
||||
ctx.notification.send('backup-failed', data).catch(err => {
|
||||
log.error('notification', 'Failed to send backup-failed', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send backup-failed' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -765,6 +782,15 @@ async function createApp() {
|
||||
})()
|
||||
}));
|
||||
|
||||
// DC-050 — Audit log viewer route. The frontend at status/js/audit-log.js
|
||||
// has been calling /api/v1/audit-logs since 2026-05-27; before this route
|
||||
// existed the dashboard silently 404'd. The audit-logger module already
|
||||
// exposes query() and clear() — this route just gives them an HTTP shape.
|
||||
apiRouter.use(auditLogRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
auditLogger: ctx.auditLogger,
|
||||
}));
|
||||
|
||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
@@ -789,6 +815,11 @@ async function createApp() {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use(caddyUpstreamRoutes({
|
||||
caddyUpstreamWatcher: ctx.caddyUpstreamWatcher,
|
||||
healthChecker: ctx.healthChecker,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
}));
|
||||
apiRouter.use('/disk', diskSpaceRoutes({
|
||||
diskSpaceMonitor: ctx.diskSpaceMonitor,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
@@ -1097,6 +1128,10 @@ async function createApp() {
|
||||
app.use('/api', notFoundHandler);
|
||||
app.use(errorMiddleware);
|
||||
|
||||
// Expose ctx on the app for entry points (server.js dashboard-WS wiring)
|
||||
// without changing the returned shape for existing callers/tests.
|
||||
app.locals.ctx = ctx;
|
||||
|
||||
return { app, log, config: config.siteConfig, licenseManager };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Disk Settings Bootstrap Loader (DC-048)
|
||||
*
|
||||
* Reads /app/data/disk-settings.json (resolved via platform-paths.dataDir)
|
||||
* at boot time and rehydrates process.env values for engine settings that
|
||||
* were previously captured only via in-memory process.env writes on the
|
||||
* POST /api/v1/disk-settings route.
|
||||
*
|
||||
* Why this exists:
|
||||
* health-checker.js, audit-logger.js, and backups.js all read
|
||||
* `process.env.HEALTH_*` / `process.env.AUDIT_MAX_ENTRIES` /
|
||||
* `process.env.BACKUP_MAX_STORAGE_BYTES` at MODULE LOAD. The previous
|
||||
* POST handler only wrote those values to process.env at runtime, so
|
||||
* any value persisted to disk-settings.json was silently discarded on
|
||||
* every container restart. Users who saved "Health Retention = 7 days"
|
||||
* would see 30 days come back at the next boot.
|
||||
*
|
||||
* Behavior:
|
||||
* - Only sets a key if process.env[key] is already UNDEFINED. Explicit
|
||||
* container / compose env still wins on cold boot (so operators can
|
||||
* override via the env without editing disk-settings.json).
|
||||
* - Logs a single INFO line at boot summarizing what was rehydrated.
|
||||
* - Never throws. A missing or malformed disk-settings.json is logged
|
||||
* and ignored — the engine falls back to its compiled-in defaults.
|
||||
*
|
||||
* Order of operations in src/app.js:
|
||||
* require('./config/disk-settings-loader')(); // ← MUST be before any
|
||||
* const healthChecker = require('./monitoring/health-checker'); // engine module
|
||||
* const auditLogger = require('./security/audit-logger'); // that reads env
|
||||
*
|
||||
* Mapping table (mirrors the POST handler in routes/disk-settings.js):
|
||||
* disk-settings.json field → process.env key
|
||||
* healthCheckInterval → HEALTH_CHECK_INTERVAL (ms)
|
||||
* healthMaxEntries → HEALTH_MAX_ENTRIES (entries)
|
||||
* healthRetentionDays → HEALTH_HISTORY_RETENTION (days)
|
||||
* statsMaxEntries → CONTAINER_STATS_MAX_ENTRIES(entries; reserved, no engine consumer yet)
|
||||
* auditMaxEntries → AUDIT_MAX_ENTRIES (entries)
|
||||
* backupMaxStorageBytes → BACKUP_MAX_STORAGE_BYTES (bytes)
|
||||
*
|
||||
* Returns an object describing what was applied — useful for tests + boot logs.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ENV_MAP = Object.freeze({
|
||||
healthCheckInterval: 'HEALTH_CHECK_INTERVAL',
|
||||
healthMaxEntries: 'HEALTH_MAX_ENTRIES',
|
||||
healthRetentionDays: 'HEALTH_HISTORY_RETENTION',
|
||||
statsMaxEntries: 'CONTAINER_STATS_MAX_ENTRIES',
|
||||
auditMaxEntries: 'AUDIT_MAX_ENTRIES',
|
||||
backupMaxStorageBytes: 'BACKUP_MAX_STORAGE_BYTES',
|
||||
});
|
||||
|
||||
// Numeric fields MUST be coerced to integers; a stray string in disk-settings.json
|
||||
// would otherwise land in process.env as a string and the next
|
||||
// parseInt(process.env.X || 'N') in the engine would silently fall back to N
|
||||
// when the value is unparseable. Defensive coercion here keeps the engine
|
||||
// consistent with the values the user just saved.
|
||||
const NUMERIC_FIELDS = Object.freeze([
|
||||
'healthCheckInterval',
|
||||
'healthMaxEntries',
|
||||
'healthRetentionDays',
|
||||
'statsMaxEntries',
|
||||
'auditMaxEntries',
|
||||
'backupMaxStorageBytes',
|
||||
]);
|
||||
|
||||
function loadPersistedSettings(dataDir) {
|
||||
if (!dataDir) return null;
|
||||
const settingsFile = path.join(dataDir, 'disk-settings.json');
|
||||
if (!fs.existsSync(settingsFile)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(settingsFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
// Log + swallow. The engine's compiled-in defaults are the safe fallback.
|
||||
// Do NOT re-throw — a malformed settings file must not stop the API from booting.
|
||||
process.stderr.write(
|
||||
`[disk-settings-loader] WARN: failed to parse ${settingsFile}: ${err.message}; using engine defaults\n`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dataDir WITHOUT importing platform-paths at the top level — the loader
|
||||
* is required very early in app.js, before platform-paths has been fully loaded
|
||||
* by sibling modules. A local require is safe (it's idempotent and side-effect
|
||||
* free — platform-paths is pure constants).
|
||||
*/
|
||||
function resolveDataDir() {
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const platformPaths = require('../../platform-paths');
|
||||
return platformPaths.dataDir;
|
||||
} catch {
|
||||
return process.env.DATA_DIR || '/etc/dashcaddy';
|
||||
}
|
||||
}
|
||||
|
||||
function applyToEnv(persisted, { logger } = {}) {
|
||||
const applied = [];
|
||||
const skipped = [];
|
||||
if (!persisted) return { applied, skipped };
|
||||
|
||||
for (const [field, envKey] of Object.entries(ENV_MAP)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(persisted, field)) continue;
|
||||
let value = persisted[field];
|
||||
if (value === null || value === undefined || value === '') continue;
|
||||
|
||||
if (NUMERIC_FIELDS.includes(field)) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
skipped.push({ field, envKey, reason: 'non-numeric' });
|
||||
continue;
|
||||
}
|
||||
value = String(Math.trunc(n));
|
||||
} else {
|
||||
value = String(value);
|
||||
}
|
||||
|
||||
if (process.env[envKey] !== undefined && process.env[envKey] !== '') {
|
||||
// Explicit env wins over persisted file. This is the only way operators
|
||||
// can override a saved value without first deleting the file.
|
||||
skipped.push({ field, envKey, reason: 'env-already-set' });
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[envKey] = value;
|
||||
applied.push({ field, envKey, value });
|
||||
}
|
||||
|
||||
return { applied, skipped };
|
||||
}
|
||||
|
||||
let hasRun = false;
|
||||
|
||||
/**
|
||||
* Run the loader once. Idempotent — second invocation is a no-op so test
|
||||
* suites that `jest.resetModules()` between cases don't re-apply values
|
||||
* from a stale persisted file across tests.
|
||||
*/
|
||||
function applyDiskSettings(options = {}) {
|
||||
if (hasRun) return { applied: [], skipped: [], alreadyRun: true };
|
||||
hasRun = true;
|
||||
|
||||
const dataDir = options.dataDir || resolveDataDir();
|
||||
const persisted = loadPersistedSettings(dataDir);
|
||||
const { applied, skipped } = applyToEnv(persisted, options);
|
||||
|
||||
const summary = {
|
||||
applied,
|
||||
skipped,
|
||||
source: persisted ? path.join(dataDir, 'disk-settings.json') : null,
|
||||
alreadyRun: false,
|
||||
};
|
||||
|
||||
if (applied.length > 0) {
|
||||
const msg = `[disk-settings-loader] rehydrated ${applied.length} setting(s) from ${summary.source}: `
|
||||
+ applied.map((a) => `${a.field}=${a.value}`).join(', ');
|
||||
// Always emit to stderr at boot — operators need to see rehydration
|
||||
// regardless of whether the app logger is wired yet (the loader runs
|
||||
// at module-load time, before app.js createApp() builds the logger).
|
||||
if (options.logger) options.logger.info(msg);
|
||||
else process.stderr.write(msg + '\n');
|
||||
} else if (skipped.length === 0 && !persisted) {
|
||||
// No persisted file: silent. (No boot noise when nothing to do.)
|
||||
} else if (skipped.length > 0) {
|
||||
const msg = `[disk-settings-loader] skipped ${skipped.length} setting(s) (env-already-set or non-numeric): `
|
||||
+ skipped.map((s) => `${s.envKey}(${s.reason})`).join(', ');
|
||||
if (options.logger) options.logger.info(msg);
|
||||
else process.stderr.write(msg + '\n');
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Exposed for tests that need to reset the once-guard between cases.
|
||||
function _resetForTesting() {
|
||||
hasRun = false;
|
||||
}
|
||||
|
||||
module.exports = applyDiskSettings;
|
||||
module.exports.applyDiskSettings = applyDiskSettings;
|
||||
module.exports._resetForTesting = _resetForTesting;
|
||||
module.exports.ENV_MAP = ENV_MAP;
|
||||
@@ -97,7 +97,7 @@ function loadAndMigrate(configFile, log) {
|
||||
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
|
||||
log.error('config-migration', e, null, { note: 'Failed to parse config.json, using defaults' });
|
||||
}
|
||||
raw = null;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function loadSiteConfig(CONFIG_FILE, log) {
|
||||
}
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('config', 'Failed to load site config', { error: e.message });
|
||||
log.error('config', e, null, { note: 'Failed to load site config' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ async function refreshDnsToken(username, password, server, fetchT, log) {
|
||||
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS token refresh error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS token refresh error' });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) {
|
||||
return await refreshDnsToken(username, password, server || primaryIp, fetchT, log);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', 'Credential manager error', { error: err.message });
|
||||
log.error('dns', err, null, { note: 'Credential manager error' });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -237,7 +237,7 @@ async function getTokenForServer(targetServer, siteConfig, credentialManager, fe
|
||||
return await authenticateToServer(username, password);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', 'Credential manager error', { server: targetServer, error: err.message });
|
||||
log.error('dns', err, null, { note: 'Credential manager error', server: targetServer });
|
||||
}
|
||||
|
||||
return { success: false, error: 'No DNS credentials configured' };
|
||||
|
||||
@@ -121,7 +121,7 @@ function assembleContext({
|
||||
try {
|
||||
fs.writeFileSync(TAILSCALE_CONFIG_FILE, JSON.stringify(meta, null, 2), 'utf8');
|
||||
} catch (e) {
|
||||
log.error('tailscale-coord', 'Failed to write tailscale-config.json', { error: e.message });
|
||||
log.error('tailscale-coord', e, null, { note: 'Failed to write tailscale-config.json' });
|
||||
}
|
||||
}
|
||||
async function getCoordClient() {
|
||||
|
||||
@@ -103,7 +103,7 @@ function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fe
|
||||
}
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS token refresh error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS token refresh error' });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,9 +172,7 @@ class DNSPropagationChecker extends EventEmitter {
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'success').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send propagation notification', {
|
||||
error: err.message
|
||||
});
|
||||
this.log.error('dns-propagation', err, null, { note: 'Failed to send propagation notification' });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -187,9 +185,7 @@ class DNSPropagationChecker extends EventEmitter {
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'warning').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send timeout notification', {
|
||||
error: err.message
|
||||
});
|
||||
this.log.error('dns-propagation', err, null, { note: 'Failed to send timeout notification' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
return await this._doLogin(username, password);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('technitium', 'Global credential error', { error: err.message });
|
||||
log.error('technitium', err, null, { note: 'Global credential error' });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -164,7 +164,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Login error', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'Login error' });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
const parsed = this._parseLogText(logText, limit);
|
||||
return { success: true, logs: parsed };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Failed to fetch DNS logs', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'Failed to fetch DNS logs' });
|
||||
throw new Error(`Failed to get DNS logs: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -449,7 +449,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
|
||||
throw new Error(result.errorMessage || 'Restart failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'DNS restart error', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'DNS restart error' });
|
||||
throw new Error(`Failed to restart DNS server: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -483,7 +483,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
|
||||
throw new Error(result.errorMessage || 'Update check failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Update check error', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'Update check error' });
|
||||
throw new Error(`Failed to check for updates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
}
|
||||
this.log.info('auto-restart', 'Policies loaded', { count: this.policies.size });
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to load policies', { error: err.message });
|
||||
this.log.error('auto-restart', err, null, { note: 'Failed to load policies' });
|
||||
}
|
||||
|
||||
// Listen to health checker status transitions
|
||||
@@ -246,7 +246,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
...eventData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
this.log.error('auto-restart', notifErr, null, { note: 'Notification failed' });
|
||||
}
|
||||
|
||||
return { action: 'max-reached', ...eventData };
|
||||
@@ -312,7 +312,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
...successData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
this.log.error('auto-restart', notifErr, null, { note: 'Notification failed' });
|
||||
}
|
||||
|
||||
this.log.info('auto-restart', 'Container restarted', {
|
||||
@@ -349,7 +349,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
...failData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
this.log.error('auto-restart', notifErr, null, { note: 'Notification failed' });
|
||||
}
|
||||
|
||||
this.log.error('auto-restart', 'Restart failed', {
|
||||
@@ -478,7 +478,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
}
|
||||
await writeJsonFile(this.policiesFile, obj);
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to save policies', { error: err.message });
|
||||
this.log.error('auto-restart', err, null, { note: 'Failed to save policies' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class ConfigDriftDetector extends EventEmitter {
|
||||
const data = await this.servicesStateManager.read();
|
||||
services = Array.isArray(data) ? data : (data.services || []);
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to read services', { error: err.message });
|
||||
this.log.error('drift', err, null, { note: 'Failed to read services' });
|
||||
}
|
||||
|
||||
// Gather live Docker containers
|
||||
@@ -83,7 +83,7 @@ class ConfigDriftDetector extends EventEmitter {
|
||||
try {
|
||||
containers = await this.docker.client.listContainers({ all: true });
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to list containers', { error: err.message });
|
||||
this.log.error('drift', err, null, { note: 'Failed to list containers' });
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
|
||||
@@ -51,7 +51,7 @@ class NotificationManager extends EventEmitter {
|
||||
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Failed to load config', { error: error.message });
|
||||
this.log.error('notification', error, null, { note: 'Failed to load config' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class NotificationManager extends EventEmitter {
|
||||
fs.writeFileSync(this.NOTIFICATIONS_FILE, JSON.stringify(this.config, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Failed to save config', { error: error.message });
|
||||
this.log.error('notification', error, null, { note: 'Failed to save config' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -429,7 +429,7 @@ class NotificationManager extends EventEmitter {
|
||||
const interval = (this.config.healthCheck?.intervalMinutes || 5) * 60 * 1000;
|
||||
this.healthDaemonInterval = setInterval(() => {
|
||||
this.checkHealth().catch(err => {
|
||||
this.log.error('notification', 'Health check failed', { error: err.message });
|
||||
this.log.error('notification', err, null, { note: 'Health check failed' });
|
||||
});
|
||||
}, interval);
|
||||
|
||||
@@ -488,7 +488,7 @@ class NotificationManager extends EventEmitter {
|
||||
lastCheck: this.config.healthCheck.lastCheck
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Health check error', { error: error.message });
|
||||
this.log.error('notification', error, null, { note: 'Health check error' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Caddy upstream watcher
|
||||
*
|
||||
* Watches every `reverse_proxy <host>` directive in /etc/caddy/sites/* and
|
||||
* independently probes each upstream every 60s. After 5 minutes of
|
||||
* consecutive failures, emits a `caddy-upstream-dead` incident via the shared
|
||||
* healthChecker so the dashboard can surface it.
|
||||
*
|
||||
* This is intentionally separate from Caddy's own `reverse_proxy` health
|
||||
* checker: Caddy probes log every failure to syslog (the noisy spam the
|
||||
* dashboard currently sees for `100.120.159.34:5000`), but Caddy never
|
||||
* surfaces the result to the dashboard or to the API. This watcher gives
|
||||
* the operator (a) a deduped view, (b) a 5-minute confirmation window so a
|
||||
* one-off blip doesn't page, and (c) a mute toggle to silence known-dead
|
||||
* upstreams without editing the Caddyfile.
|
||||
*
|
||||
* State persisted to <dataDir>/caddy-upstreams.json. Mute list is part of
|
||||
* the same file so atomic-write semantics keep state + mutes consistent.
|
||||
*
|
||||
* The probe DOES NOT use Caddy's health_uri (that's Caddy's own probe and
|
||||
* the source of the spam). The probe also stamps `X-DashCaddy-HealthCheck: 1`
|
||||
* so the `dashcaddy_auth` forward_auth gate on *.sami bypasses for probes
|
||||
* (same trick as src/monitoring/health-checker.js _doRequest).
|
||||
*
|
||||
* @module caddy-upstream-watcher
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const EventEmitter = require('events');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
/** Default probe interval: 60s. Independent of Caddy's 10s internal probe. */
|
||||
const PROBE_INTERVAL_MS = parseInt(process.env.CADDY_UPSTREAM_PROBE_INTERVAL_MS || '60000', 10);
|
||||
|
||||
/** Per-probe timeout. Short — these are liveness pings, not full requests. */
|
||||
const PROBE_TIMEOUT_MS = parseInt(process.env.CADDY_UPSTREAM_PROBE_TIMEOUT_MS || '5000', 10);
|
||||
|
||||
/** After this many ms of continuous failure, emit a "dead" incident. */
|
||||
const DEAD_AFTER_MS = parseInt(process.env.CADDY_UPSTREAM_DEAD_AFTER_MS || (5 * 60 * 1000), 10);
|
||||
|
||||
/** After this many ms of continuous success, auto-resolve any open incident. */
|
||||
const RESOLVED_AFTER_MS = parseInt(process.env.CADDY_UPSTREAM_RESOLVED_AFTER_MS || (60 * 1000), 10);
|
||||
|
||||
/** Status codes that prove the upstream answered. 4xx auth-walled counts as up. */
|
||||
const HEALTHY_CODES = new Set([200, 201, 204, 301, 302, 303, 307, 308, 401, 403, 429]);
|
||||
|
||||
const STATE_FILE = process.env.CADDY_UPSTREAMS_STATE_FILE
|
||||
|| path.join(platformPaths.dataDir || path.dirname(platformPaths.configFile || '.'), 'caddy-upstreams.json');
|
||||
|
||||
const SITES_DIR = process.env.CADDY_SITES_DIR || '/etc/caddy/sites';
|
||||
|
||||
class CaddyUpstreamWatcher extends EventEmitter {
|
||||
constructor(opts = {}) {
|
||||
super();
|
||||
this.log = opts.log || console;
|
||||
this.healthChecker = opts.healthChecker || null;
|
||||
/** Map<string, UpstreamState> keyed by host (host[:port]) */
|
||||
this.upstreams = new Map();
|
||||
/** Set<string> hosts the user has muted */
|
||||
this.muted = new Set();
|
||||
/** Set<string> incident IDs currently open — prevents duplicate incidents */
|
||||
this.openIncidents = new Set();
|
||||
this.timer = null;
|
||||
this.checking = false;
|
||||
this.scanTimer = null;
|
||||
this._loadState();
|
||||
}
|
||||
|
||||
/** Begin watching. Idempotent — safe to call twice. */
|
||||
start() {
|
||||
if (this.checking) return;
|
||||
this.checking = true;
|
||||
// Initial scan + probe so the dashboard has data immediately after boot.
|
||||
this.scanSites().catch((e) => this.log.warn('caddy-upstream-watcher', e?.message || String(e)));
|
||||
this.timer = setInterval(() => this._tick().catch(() => {}), PROBE_INTERVAL_MS);
|
||||
// Re-scan sites every 5 min so newly added sites get picked up.
|
||||
this.scanTimer = setInterval(() => this.scanSites().catch(() => {}), 5 * 60 * 1000);
|
||||
this.log.info?.('caddy-upstream-watcher', 'started', {
|
||||
probeIntervalMs: PROBE_INTERVAL_MS,
|
||||
deadAfterMs: DEAD_AFTER_MS,
|
||||
stateFile: STATE_FILE,
|
||||
sitesDir: SITES_DIR
|
||||
}) ?? this.log.info?.('caddy-upstream-watcher', 'started');
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.checking) return;
|
||||
this.checking = false;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.scanTimer) clearInterval(this.scanTimer);
|
||||
this.timer = null;
|
||||
this.scanTimer = null;
|
||||
}
|
||||
|
||||
/** Parse /etc/caddy/sites/* and seed/refresh the upstream map. */
|
||||
async scanSites() {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(SITES_DIR);
|
||||
} catch (e) {
|
||||
// Sites dir might not exist in dev — that's OK, just skip.
|
||||
this.log.warn?.('caddy-upstream-watcher', `cannot read ${SITES_DIR}: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
for (const entry of entries) {
|
||||
// Caddy `import` sites have a wild mix of extensions: `.sami`,
|
||||
// `.caddy`, `.conf` — and ALSO bare hostnames like
|
||||
// `zap.sami-ahmed.net`, `samitest.space`, `blocks.cryptographic-triangles.org`
|
||||
// where the "extension" is `.net`/`.space`/`.org`. Filter out known
|
||||
// non-site junk (readmes, .bak) and accept everything else; the
|
||||
// reverse_proxy parse below is the real validation.
|
||||
if (/^README|\.bak$|\.swp$|^\.|^#/.test(entry)) continue;
|
||||
if (entry === 'Caddyfile' || entry === 'caddyfile') continue;
|
||||
const filePath = path.join(SITES_DIR, entry);
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (_) { continue; }
|
||||
|
||||
// Cheap pre-check: skip files with no reverse_proxy and no brace block
|
||||
// (README files, .gitignore, etc.). The reverse_proxy regex below is
|
||||
// the authoritative parse, but this avoids regex-scanning every
|
||||
// unrelated file in the directory.
|
||||
if (!/reverse_proxy/i.test(content)) continue;
|
||||
|
||||
// Capture the site block host from the first line: e.g. "arch.sami {"
|
||||
const siteMatch = content.match(/^\s*([a-z0-9._-]+)\s*\{/im);
|
||||
const siteName = siteMatch ? siteMatch[1] : entry.replace(/\.(sami|caddy|conf)$/i, '');
|
||||
|
||||
// Find every reverse_proxy <host[:port]> directive. Match common shapes:
|
||||
// reverse_proxy 100.120.159.34:5000 { ... }
|
||||
// reverse_proxy http://100.120.159.34:5000 { ... }
|
||||
// reverse_proxy 100.120.159.34:5000
|
||||
const re = /reverse_proxy\s+(?:https?:\/\/)?([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[a-z0-9._-]+)(?::(\d+))?/gi;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const host = m[1];
|
||||
let port = m[2];
|
||||
if (!port) {
|
||||
if (m[0].includes('https')) port = '443';
|
||||
else if (m[0].includes('http://')) port = '80';
|
||||
else port = '';
|
||||
}
|
||||
const key = port ? `${host}:${port}` : host;
|
||||
seen.add(key);
|
||||
if (!this.upstreams.has(key)) {
|
||||
this.upstreams.set(key, {
|
||||
host: key,
|
||||
ip: host,
|
||||
port: port || null,
|
||||
site: siteName,
|
||||
siteFile: entry,
|
||||
consecutiveFailures: 0,
|
||||
lastFailureAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
lastCheckedAt: null,
|
||||
status: 'unknown'
|
||||
});
|
||||
} else {
|
||||
// Refresh site name/file in case the file was renamed.
|
||||
const u = this.upstreams.get(key);
|
||||
u.site = siteName;
|
||||
u.siteFile = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop upstreams that disappeared from the Caddyfile (removed/renamed site).
|
||||
for (const key of Array.from(this.upstreams.keys())) {
|
||||
if (!seen.has(key)) this.upstreams.delete(key);
|
||||
}
|
||||
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
/** Single probe tick over every upstream. */
|
||||
async _tick() {
|
||||
const probes = [];
|
||||
for (const u of this.upstreams.values()) {
|
||||
if (this.muted.has(u.host)) continue;
|
||||
probes.push(this._probeOne(u).catch((e) => {
|
||||
this.log.warn?.('caddy-upstream-watcher', `probe failed for ${u.host}: ${e.message}`);
|
||||
}));
|
||||
}
|
||||
await Promise.all(probes);
|
||||
this._saveState();
|
||||
this.emit('tick', this.snapshot());
|
||||
}
|
||||
|
||||
/** Probe a single upstream and update state. */
|
||||
async _probeOne(u) {
|
||||
const result = await this._doProbe(u.ip, u.port);
|
||||
u.lastCheckedAt = new Date().toISOString();
|
||||
|
||||
if (result.healthy) {
|
||||
u.consecutiveFailures = 0;
|
||||
u.lastSuccessAt = u.lastCheckedAt;
|
||||
u.lastError = null;
|
||||
// Resolve open incident if upstream is healthy for RESOLVED_AFTER_MS.
|
||||
this._maybeResolve(u);
|
||||
// Only flip to 'up' if the upstream has been healthy long enough to not
|
||||
// be a flapping signal — short blips are normal and we want the dashboard
|
||||
// to be stable. After one full successful check we mark 'up' but the
|
||||
// incident resolution waits for RESOLVED_AFTER_MS.
|
||||
u.status = 'up';
|
||||
} else {
|
||||
u.consecutiveFailures += 1;
|
||||
u.lastFailureAt = u.lastCheckedAt;
|
||||
u.lastError = result.error || `HTTP ${result.statusCode || 'unknown'}`;
|
||||
// First failure flips status to 'down' immediately for the dashboard, but
|
||||
// we only OPEN an incident after the upstream has been continuously failing
|
||||
// for DEAD_AFTER_MS (5 min by default) so a single transient blip doesn't
|
||||
// page anyone.
|
||||
u.status = 'down';
|
||||
this._maybeOpenIncident(u);
|
||||
}
|
||||
}
|
||||
|
||||
_maybeOpenIncident(u) {
|
||||
if (!this.healthChecker) return;
|
||||
// "failingForMs" = continuous time the upstream has been unhealthy.
|
||||
// Use lastSuccessAt as the anchor — if it was up 7min ago and is still
|
||||
// down now, that's 7 minutes of continuous failure regardless of how many
|
||||
// individual probe failures have piled up in between. Falls back to
|
||||
// consecutiveFailures * interval when there's no success anchor (e.g. we've
|
||||
// never seen the upstream healthy since startup).
|
||||
const lastSuccessMs = u.lastSuccessAt ? new Date(u.lastSuccessAt).getTime() : null;
|
||||
const failingForMs = lastSuccessMs !== null
|
||||
? Math.max(0, Date.now() - lastSuccessMs)
|
||||
: u.consecutiveFailures * PROBE_INTERVAL_MS;
|
||||
if (failingForMs < DEAD_AFTER_MS) return;
|
||||
if (this.openIncidents.has(u.host)) return;
|
||||
|
||||
// Mimic the shape HealthChecker.createIncident expects.
|
||||
try {
|
||||
this.healthChecker.createIncident(u.host, 'caddy-upstream-dead',
|
||||
`Caddy upstream ${u.host} (site ${u.site}) unreachable for ${Math.round(failingForMs / 60000)}m: ${u.lastError || 'no response'}`,
|
||||
{
|
||||
serviceId: u.host,
|
||||
timestamp: u.lastFailureAt,
|
||||
status: 'down',
|
||||
error: u.lastError,
|
||||
details: { site: u.site, siteFile: u.siteFile }
|
||||
}
|
||||
);
|
||||
this.openIncidents.add(u.host);
|
||||
this.emit('upstream-dead', u);
|
||||
this.log.warn?.('caddy-upstream-watcher', `upstream dead: ${u.host} (${u.site})`);
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `incident create failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_maybeResolve(u) {
|
||||
if (!this.healthChecker) return;
|
||||
if (!this.openIncidents.has(u.host)) return;
|
||||
const downSince = u.lastFailureAt ? new Date(u.lastFailureAt).getTime() : 0;
|
||||
const recoveredForMs = downSince ? Date.now() - downSince : 0;
|
||||
if (recoveredForMs < RESOLVED_AFTER_MS) return;
|
||||
try {
|
||||
this.healthChecker.resolveIncident(u.host, 'caddy-upstream-dead', {
|
||||
serviceId: u.host,
|
||||
timestamp: u.lastSuccessAt || new Date().toISOString(),
|
||||
status: 'up'
|
||||
});
|
||||
this.openIncidents.delete(u.host);
|
||||
this.emit('upstream-recovered', u);
|
||||
this.log.info?.('caddy-upstream-watcher', `upstream recovered: ${u.host}`);
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `incident resolve failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_doProbe(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
const isHttps = port === '443';
|
||||
const lib = isHttps ? https : http;
|
||||
const opts = {
|
||||
hostname: host,
|
||||
port: port || (isHttps ? 443 : 80),
|
||||
method: 'HEAD',
|
||||
path: '/',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
headers: { 'X-DashCaddy-HealthCheck': '1', 'User-Agent': 'DashCaddy-CaddyUpstreamWatcher/1' },
|
||||
rejectUnauthorized: false
|
||||
};
|
||||
const req = lib.request(opts, (res) => {
|
||||
res.resume();
|
||||
const healthy = HEALTHY_CODES.has(res.statusCode);
|
||||
resolve({ healthy, statusCode: res.statusCode });
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('probe timeout'));
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
resolve({ healthy: false, error: err.message });
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Public snapshot for the API/UI. */
|
||||
snapshot() {
|
||||
const list = [];
|
||||
for (const u of this.upstreams.values()) {
|
||||
const muted = this.muted.has(u.host);
|
||||
// Same anchor as _maybeOpenIncident: time since the last successful
|
||||
// probe. If we've never seen a success, fall back to consecutive
|
||||
// failures × probe interval as a worst-case lower bound.
|
||||
const lastSuccessMs = u.lastSuccessAt ? new Date(u.lastSuccessAt).getTime() : null;
|
||||
let failingFor = 0;
|
||||
if (!muted) {
|
||||
if (lastSuccessMs !== null) {
|
||||
failingFor = Math.max(0, Date.now() - lastSuccessMs);
|
||||
} else if (u.status === 'down') {
|
||||
failingFor = u.consecutiveFailures * PROBE_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
list.push({
|
||||
host: u.host,
|
||||
site: u.site,
|
||||
siteFile: u.siteFile,
|
||||
status: muted ? 'muted' : u.status,
|
||||
consecutiveFailures: u.consecutiveFailures,
|
||||
lastCheckedAt: u.lastCheckedAt,
|
||||
lastSuccessAt: u.lastSuccessAt,
|
||||
lastFailureAt: u.lastFailureAt,
|
||||
lastError: u.lastError,
|
||||
failingForMs: failingFor,
|
||||
muted,
|
||||
dead: !muted && failingFor >= DEAD_AFTER_MS
|
||||
});
|
||||
}
|
||||
// Sort: dead first, then down, then up, then unknown. Within each, by host.
|
||||
list.sort((a, b) => {
|
||||
const order = { dead: 0, down: 1, muted: 2, up: 3, unknown: 4 };
|
||||
const oa = order[a.dead ? 'dead' : a.status] ?? 9;
|
||||
const ob = order[b.dead ? 'dead' : b.status] ?? 9;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.host.localeCompare(b.host);
|
||||
});
|
||||
return {
|
||||
upstreams: list,
|
||||
config: {
|
||||
probeIntervalMs: PROBE_INTERVAL_MS,
|
||||
deadAfterMs: DEAD_AFTER_MS,
|
||||
resolvedAfterMs: RESOLVED_AFTER_MS,
|
||||
sitesDir: SITES_DIR
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setMuted(host, muted) {
|
||||
if (muted) {
|
||||
this.muted.add(host);
|
||||
} else {
|
||||
this.muted.delete(host);
|
||||
// Reset failure state on unmute so we don't immediately re-incident a
|
||||
// upstream that just came off mute.
|
||||
const u = this.upstreams.get(host);
|
||||
if (u) {
|
||||
u.consecutiveFailures = 0;
|
||||
u.lastError = null;
|
||||
u.lastFailureAt = null;
|
||||
u.status = 'unknown';
|
||||
}
|
||||
}
|
||||
this._saveState();
|
||||
return { host, muted: !!muted };
|
||||
}
|
||||
|
||||
isMuted(host) { return this.muted.has(host); }
|
||||
|
||||
_loadState() {
|
||||
try {
|
||||
if (!fs.existsSync(STATE_FILE)) return;
|
||||
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
if (Array.isArray(data.muted)) this.muted = new Set(data.muted);
|
||||
// Don't reload upstreams from disk — sites dir is the source of truth.
|
||||
// But preserve last-check state for hosts that still exist.
|
||||
if (data.upstreams && typeof data.upstreams === 'object') {
|
||||
this._restoreUpstreamStates(data.upstreams);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `state load failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_restoreUpstreamStates(persisted) {
|
||||
for (const [host, st] of Object.entries(persisted)) {
|
||||
if (this.upstreams.has(host)) continue;
|
||||
this.upstreams.set(host, {
|
||||
host,
|
||||
ip: st.ip || host.split(':')[0],
|
||||
port: st.port || null,
|
||||
site: st.site || '',
|
||||
siteFile: st.siteFile || '',
|
||||
consecutiveFailures: st.consecutiveFailures || 0,
|
||||
lastFailureAt: st.lastFailureAt || null,
|
||||
lastSuccessAt: st.lastSuccessAt || null,
|
||||
lastError: st.lastError || null,
|
||||
lastCheckedAt: st.lastCheckedAt || null,
|
||||
status: 'unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_saveState() {
|
||||
try {
|
||||
const dir = path.dirname(STATE_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const upstreams = {};
|
||||
for (const [k, v] of this.upstreams.entries()) {
|
||||
upstreams[k] = {
|
||||
ip: v.ip,
|
||||
port: v.port,
|
||||
site: v.site,
|
||||
siteFile: v.siteFile,
|
||||
consecutiveFailures: v.consecutiveFailures,
|
||||
lastFailureAt: v.lastFailureAt,
|
||||
lastSuccessAt: v.lastSuccessAt,
|
||||
lastError: v.lastError,
|
||||
lastCheckedAt: v.lastCheckedAt
|
||||
};
|
||||
}
|
||||
const tmp = STATE_FILE + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify({ muted: Array.from(this.muted), upstreams }, null, 2));
|
||||
fs.renameSync(tmp, STATE_FILE);
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `state save failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — matches the pattern of health-checker.js so it integrates
|
||||
// without a separate instantiation site.
|
||||
module.exports = new CaddyUpstreamWatcher();
|
||||
module.exports.CaddyUpstreamWatcher = CaddyUpstreamWatcher;
|
||||
@@ -331,7 +331,7 @@ class DiskSpaceMonitor extends EventEmitter {
|
||||
result.error = err.message;
|
||||
result.completedAt = new Date().toISOString();
|
||||
if (this.log) {
|
||||
this.log.error('disk', 'Disk cleanup failed', { error: err.message, level });
|
||||
this.log.error('disk', err, null, { note: 'Disk cleanup failed', level });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ class SSLMonitor extends EventEmitter {
|
||||
try {
|
||||
servicesData = await this.ctx.servicesStateManager.read();
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to read services', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Failed to read services' });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
@@ -212,13 +212,13 @@ class SSLMonitor extends EventEmitter {
|
||||
|
||||
// Initial check (non-blocking)
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Initial SSL check failed' });
|
||||
});
|
||||
|
||||
// Schedule periodic checks
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Periodic SSL check failed' });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
|
||||
@@ -299,7 +299,7 @@ class SSLMonitor extends EventEmitter {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Periodic SSL check failed' });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
}
|
||||
@@ -355,7 +355,7 @@ class SSLMonitor extends EventEmitter {
|
||||
validTo: certResult.validTo
|
||||
}, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning');
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Failed to send SSL notification' });
|
||||
}
|
||||
}
|
||||
} else if (level === null) {
|
||||
|
||||
@@ -81,7 +81,7 @@ class PluginManager extends EventEmitter {
|
||||
workflowActions: [...this.workflowActions.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.error('plugins', 'Failed to scan plugin directory', { error: err.message });
|
||||
this.log.error('plugins', err, null, { note: 'Failed to scan plugin directory' });
|
||||
this.loaded = true; // Don't crash — just run without plugins
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,9 @@
|
||||
* ./error-logger.js and its ./error.log file have been retired.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { AppError } = require('./errors');
|
||||
const { LIMITS } = require('./constants');
|
||||
const { logError: unifiedLogError, safeErrorMessage } = require('../utils/logging');
|
||||
const { errorResponse } = require('../utils/responses');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(platformPaths.dataDir, 'error.log');
|
||||
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||||
|
||||
/**
|
||||
* Global error handling middleware
|
||||
@@ -24,11 +18,10 @@ const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||||
function errorMiddleware(err, req, res, next) {
|
||||
// Log all errors with request context (unified, same file the rest of the app uses)
|
||||
unifiedLogError(
|
||||
ERROR_LOG_FILE,
|
||||
MAX_ERROR_LOG_SIZE,
|
||||
req.path,
|
||||
err,
|
||||
{
|
||||
req,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userId: req.user?.id,
|
||||
|
||||
@@ -228,7 +228,7 @@ async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateMana
|
||||
log.info('health', 'Health checker synced', { added, updated, removed });
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('health', 'Error syncing health checker', { error: error.message });
|
||||
log.error('health', error, null, { note: 'Error syncing health checker' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -417,6 +417,14 @@ function safeErrorMessage(error) {
|
||||
// Supports: logError(context, error, extra) → existing route call pattern
|
||||
|
||||
async function logErrorWrapper(ctx, err, extra) {
|
||||
// Guard against legacy call shapes that used to corrupt error.log:
|
||||
// the old 5-arg form logError(file, maxSize, path, err, meta) made ctx
|
||||
// a file path and turned maxSize (a number) into the "error". Detect and
|
||||
// normalize so the real error always reaches error.log.
|
||||
if (typeof ctx === 'string' && /^\/.*\.(log|json)$/.test(ctx) && typeof err === 'number') {
|
||||
// Legacy shape: (file, size, reqPath, error, meta) → shift args.
|
||||
[ctx, err, extra] = [arguments[2], arguments[3], { ...arguments[4], req: undefined }];
|
||||
}
|
||||
const req = extra?.req;
|
||||
const payload = extra ? { ...extra } : {};
|
||||
if (payload.req) delete payload.req;
|
||||
|
||||
Vendored
+89
-89
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+10
-9
@@ -203,6 +203,7 @@
|
||||
<div class="tools-section-items">
|
||||
<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>
|
||||
<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="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||
@@ -694,9 +695,9 @@
|
||||
<span style="font-size: 1.5rem; line-height: 1.2;">⚠️</span>
|
||||
<div style="font-size: 0.92rem; line-height: 1.55; color: var(--text);">
|
||||
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage Note:</strong>
|
||||
DashCaddy stores health check history, container statistics, and event logs.
|
||||
On a busy server, this data can accumulate over time.
|
||||
Set appropriate retention limits in <strong>Settings → Health</strong> to prevent disk fill.
|
||||
DashCaddy stores up to <strong>500 health check entries per service</strong> (plus container statistics and event logs).
|
||||
At high check frequencies this may consume significant disk space — on a host with many services the history file can grow to tens of megabytes.
|
||||
Adjust the <strong>health check interval</strong>, <strong>max entries per service</strong>, and <strong>health retention period</strong> in <strong>Disk Safety</strong> (the 💾 Disk button in the top bar) to control disk usage.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,12 +705,12 @@
|
||||
<div style="margin-top: 16px; padding: 14px 16px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<strong style="font-size: 0.9rem;">📋 Recommended after setup</strong>
|
||||
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 0.85rem; color: var(--muted); line-height: 1.6;">
|
||||
<li>Open <strong>Health → Configure → Global Settings</strong></li>
|
||||
<li>Set a <strong>health check polling interval</strong> (default: 60s)</li>
|
||||
<li>Set a <strong>stats polling interval</strong> (default: 30s)</li>
|
||||
<li>Set a <strong>data retention period</strong> (default: 30 days)</li>
|
||||
<li>Cap <strong>max entries per service</strong> (default: 500)</li>
|
||||
<li>Set a <strong>disk-usage warning threshold</strong> (default: 80%)</li>
|
||||
<li>Open the <strong>💾 Disk</strong> button in the top bar (opens the Disk Safety modal)</li>
|
||||
<li>Set a <strong>health check polling interval</strong> (default: 30s)</li>
|
||||
<li>Set a <strong>health retention period</strong> (default: 30 days)</li>
|
||||
<li>Cap <strong>max health entries per service</strong> (default: 500)</li>
|
||||
<li>Cap <strong>max stats entries</strong> (default: 500)</li>
|
||||
<li>Click <strong>Clean Up Now</strong> to purge old data immediately</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
+136
-17
@@ -1,17 +1,20 @@
|
||||
// ========== AUDIT LOG VIEWER ==========
|
||||
// DC-050: surface authenticated user identity (userEmail / userRole from
|
||||
// auditLogger.details), add outcome filter, pass confirm=CLEAR body for
|
||||
// destructive DELETE.
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('audit-modal', `<div id="audit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
||||
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
|
||||
<h3>📜 Audit Log</h3>
|
||||
<p class="modal-subtitle">
|
||||
Track all actions performed through the API.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
||||
<label class="text-muted-sm">Filter:</label>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<label class="text-muted-sm">Category:</label>
|
||||
<select id="audit-filter" 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 Actions</option>
|
||||
<option value="">All</option>
|
||||
<option value="service">Services</option>
|
||||
<option value="container">Containers</option>
|
||||
<option value="caddy">Caddy</option>
|
||||
@@ -20,6 +23,16 @@
|
||||
<option value="config">Config</option>
|
||||
<option value="auth">Auth</option>
|
||||
</select>
|
||||
<label class="text-muted-sm">Result:</label>
|
||||
<select id="audit-outcome-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="">Any</option>
|
||||
<option value="success">✓ Success</option>
|
||||
<option value="failure">✗ Failure</option>
|
||||
</select>
|
||||
<label class="text-muted-sm" style="margin-left: 8px;">Since:</label>
|
||||
<input id="audit-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="audit-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="audit-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
||||
<span style="flex: 1;"></span>
|
||||
<button id="audit-clear-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑️ Clear Log</button>
|
||||
@@ -45,26 +58,84 @@
|
||||
const refreshBtn = document.getElementById('audit-refresh-btn');
|
||||
const clearBtn = document.getElementById('audit-clear-btn');
|
||||
const filterSelect = document.getElementById('audit-filter');
|
||||
const outcomeSelect = document.getElementById('audit-outcome-filter');
|
||||
const sinceInput = document.getElementById('audit-since');
|
||||
const untilInput = document.getElementById('audit-until');
|
||||
const container = document.getElementById('audit-log-container');
|
||||
const loadMoreBtn = document.getElementById('audit-load-more');
|
||||
let currentOffset = 0;
|
||||
let inflight = null; // AbortController for the in-flight request
|
||||
let filterNonce = 0; // increments on every fresh (non-append) load; lets
|
||||
// an in-flight append detect the filter has changed
|
||||
// and skip its DOM splice.
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// datetime-local fields carry no timezone offset — convert to ISO 8601
|
||||
// with the local offset so the server can compare correctly.
|
||||
function toIso(localDtValue) {
|
||||
if (!localDtValue) return null;
|
||||
// Browsers expose datetime-local as naive local time. new Date() on
|
||||
// that string parses it as LOCAL, so toISOString() yields the UTC
|
||||
// equivalent the server expects.
|
||||
const d = new Date(localDtValue);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function loadAudit(append) {
|
||||
try {
|
||||
if (!append) {
|
||||
// Cancel any pending request and bump the filter nonce so any
|
||||
// appending fetch (still in flight) knows to discard its response.
|
||||
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 = new URLSearchParams();
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
params.set('offset', String(currentOffset));
|
||||
const action = filterSelect.value;
|
||||
const outcome = outcomeSelect.value;
|
||||
const since = toIso(sinceInput.value);
|
||||
const until = toIso(untilInput.value);
|
||||
if (action) params.set('action', action);
|
||||
if (outcome) params.set('outcome', outcome);
|
||||
if (since) params.set('since', since);
|
||||
if (until) params.set('until', until);
|
||||
|
||||
const res = await fetch('/api/v1/audit-logs?' + params.toString(), {
|
||||
signal: inflight.signal,
|
||||
});
|
||||
// Surface 401/403/500 explicitly — the dashboard used to render any
|
||||
// non-success response as "no audit log entries yet," which is
|
||||
// misleading for an expired session.
|
||||
if (!res.ok) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${res.status}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const filter = filterSelect.value;
|
||||
let url = `/api/v1/audit-logs?limit=${PAGE_SIZE}&offset=${currentOffset}`;
|
||||
if (filter) url += `&action=${encodeURIComponent(filter)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const entries = data.success && data.entries ? data.entries : [];
|
||||
if (!data.success) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(data.error || 'unknown')}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// If a non-append load happened after this fetch was issued, the
|
||||
// operator changed filters; discard the now-stale response.
|
||||
if (!append && myNonce !== filterNonce) return;
|
||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
|
||||
if (entries.length === 0 && !append) {
|
||||
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">📜</span>No audit log entries yet. Actions will be logged automatically.</div>';
|
||||
const reason = data.filters && (data.filters.action || data.filters.outcome || data.filters.since || data.filters.until)
|
||||
? 'No entries match your filters.'
|
||||
: 'No audit log entries yet. Actions will be logged automatically.';
|
||||
container.innerHTML = `<div class="panel-empty"><span class="empty-icon">📜</span>${escapeHtml(reason)}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -72,20 +143,29 @@
|
||||
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);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">';
|
||||
html += '<th style="padding: 6px; text-align: left;">When</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Actor</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">IP</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Action</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Resource</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Result</th>';
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const ok = e.outcome === 'success';
|
||||
const actor = actorLabel(e);
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(e.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp || '')}">${timeAgo(e.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; font-size: 0.78rem;">${actor}</td>`;
|
||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(e.ip || '-')}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(e.action || '-')}</td>`;
|
||||
html += `<td style="padding: 6px;">${escapeHtml(e.resource || '-')}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'}</span></td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'} ${escapeHtml(e.outcome || '')}</span></td>`;
|
||||
html += '</tr>';
|
||||
if (e.details && Object.keys(e.details).length > 0) {
|
||||
html += `<tr class="audit-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;">${escapeHtml(JSON.stringify(e.details, null, 2))}</pre></td></tr>`;
|
||||
html += `<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(e.details, null, 2))}</pre></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +173,14 @@
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
// Append rows to existing table
|
||||
const table = container.querySelector('table');
|
||||
if (table) table.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
currentOffset += entries.length;
|
||||
loadMoreBtn.style.display = entries.length >= PAGE_SIZE ? '' : 'none';
|
||||
// hasMore is reported by the server (post-filter total), so the
|
||||
// Load More button stays accurate when filters change mid-scroll.
|
||||
loadMoreBtn.style.display = data.hasMore ? '' : 'none';
|
||||
|
||||
// Toggle detail rows on click
|
||||
container.querySelectorAll('.audit-row').forEach(row => {
|
||||
@@ -113,10 +194,34 @@
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
// AbortError is expected when we deliberately cancel an in-flight
|
||||
// request (e.g. the operator changed filters mid-fetch) — don't
|
||||
// flash a "Failed: The user aborted a request" message over the
|
||||
// loading spinner. The new fetch has already kicked off.
|
||||
if (e && e.name === 'AbortError') return;
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the human-readable actor: prefer userEmail, fall back to
|
||||
// userId, fall back to bare IP. If no user attribution, mark as
|
||||
// "system" so the operator knows the entry came from an unauthenticated
|
||||
// or service path (e.g. cron-driven backups).
|
||||
function actorLabel(entry) {
|
||||
const d = entry.details || {};
|
||||
const email = d.userEmail;
|
||||
const id = d.userId;
|
||||
const role = d.userRole;
|
||||
const provider = d.viaProvider;
|
||||
if (email) {
|
||||
const tag = role ? ` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(role)}${provider ? '/' + escapeHtml(provider) : ''}]</span>` : '';
|
||||
return `${escapeHtml(email)}${tag}`;
|
||||
}
|
||||
if (id) return `<span style="font-family: monospace; color: var(--muted);">${escapeHtml(id)}</span>`;
|
||||
if (!entry.ip) return '<span style="color: var(--muted);">system</span>';
|
||||
return '<span style="color: var(--muted);">anon</span>';
|
||||
}
|
||||
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal?.classList.add('show');
|
||||
loadAudit(false);
|
||||
@@ -124,12 +229,26 @@
|
||||
wireModal(modal, cancelBtn);
|
||||
refreshBtn?.addEventListener('click', () => loadAudit(false));
|
||||
filterSelect?.addEventListener('change', () => loadAudit(false));
|
||||
outcomeSelect?.addEventListener('change', () => loadAudit(false));
|
||||
// Re-fetch on date change only when both fields have a value or both are
|
||||
// empty — typing one character shouldn't trigger a fetch for every keystroke.
|
||||
let dateDebounce;
|
||||
[sinceInput, untilInput].forEach((el) => {
|
||||
el?.addEventListener('change', () => {
|
||||
clearTimeout(dateDebounce);
|
||||
dateDebounce = setTimeout(() => loadAudit(false), 250);
|
||||
});
|
||||
});
|
||||
loadMoreBtn?.addEventListener('click', () => loadAudit(true));
|
||||
|
||||
clearBtn?.addEventListener('click', async () => {
|
||||
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/audit-logs', { method: 'DELETE' });
|
||||
const res = await secureFetch('/api/v1/audit-logs', {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) loadAudit(false);
|
||||
else showNotification('Error: ' + (data.error || 'Clear failed'), 'error');
|
||||
|
||||
+18
-1
@@ -365,6 +365,9 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
// Skip if auth has been lost (e.g. TOTP gate activated externally).
|
||||
// The polling interval in init.js also checks this flag.
|
||||
if (window._dcAuthLost) return;
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true;
|
||||
return refreshInFlight;
|
||||
@@ -417,9 +420,21 @@
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/services/status', { cache: 'no-store' });
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Auth lost — stop the polling loop and close SSE; do NOT fall
|
||||
// through to direct probes (those would misleadingly mark
|
||||
// services as healthy since /probe/ treats 401/403 as "up").
|
||||
window._dcAuthLost = true;
|
||||
if (window._sseReconnect && window._sseClose) {
|
||||
window._sseClose(); // tell SSE to stop reconnecting
|
||||
}
|
||||
updateStamp('auth required');
|
||||
return; // skip the fallback entirely
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status refresh failed (${response.status})`);
|
||||
}
|
||||
window._dcAuthLost = false; // auth working again
|
||||
const data = await response.json();
|
||||
applyBatchResults(data.statuses || {});
|
||||
updateStamp('last check', data.checkedAt || new Date());
|
||||
@@ -434,9 +449,11 @@
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
if (refreshQueued) {
|
||||
if (refreshQueued && !window._dcAuthLost) {
|
||||
refreshQueued = false;
|
||||
setTimeout(() => { window.refreshAll(); }, 0);
|
||||
} else {
|
||||
refreshQueued = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -63,7 +63,12 @@
|
||||
window.buildGrid();
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||
setInterval(() => {
|
||||
// Stop polling if the session has been invalidated (e.g. TOTP gate
|
||||
// now active, or user logged out). Avoids relentless 401/403 noise.
|
||||
if (window._dcAuthLost) return;
|
||||
window.refreshAll();
|
||||
}, DC.POLL.DASHBOARD);
|
||||
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
||||
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
|
||||
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
||||
|
||||
@@ -47,10 +47,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:rgba(243,156,18,0.08);border:1px solid rgba(243,156,18,0.25);border-radius:8px;padding:12px;margin-bottom:16px;">
|
||||
<div style="font-size:0.82rem;color:#f0a040;line-height:1.45;">
|
||||
⚠ <strong>Disk impact:</strong> Lowering the health check interval increases how often data is written to disk.
|
||||
DashCaddy caps history at <strong>max entries per service</strong> and prunes entries older than the retention period,
|
||||
so these two values together determine steady-state disk usage. For busy hosts, prefer a longer interval (60–120s)
|
||||
and a lower entry cap.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:16px;">
|
||||
${settingRow('Health Check Interval', 'healthInterval', c.healthCheckInterval, 'seconds', Math.round((c.healthCheckInterval||30000)/1000), 5, 300)}
|
||||
${settingRow('Max Health Entries/Service', 'healthMaxEntries', c.healthMaxEntries, 'entries', c.healthMaxEntries||500, 50, 5000)}
|
||||
${settingRow('Health Retention', 'healthRetentionDays', c.healthRetentionDays, 'days', c.healthRetentionDays||14, 1, 90)}
|
||||
${settingRow('Health Retention', 'healthRetentionDays', c.healthRetentionDays, 'days', c.healthRetentionDays||30, 1, 90)}
|
||||
${settingRow('Max Stats Entries', 'statsMaxEntries', c.statsMaxEntries, 'entries', c.statsMaxEntries||500, 100, 10000)}
|
||||
${settingRow('Max Audit Entries', 'auditMaxEntries', c.auditMaxEntries, 'entries', c.auditMaxEntries||1000, 100, 10000)}
|
||||
</div>
|
||||
|
||||
+81
-9
@@ -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>';
|
||||
}
|
||||
@@ -68,5 +138,7 @@
|
||||
|
||||
refreshBtn?.addEventListener('click', loadErrorLogs);
|
||||
clearBtn?.addEventListener('click', clearErrorLogs);
|
||||
levelSelect?.addEventListener('change', loadErrorLogs);
|
||||
tailSelect?.addEventListener('change', loadErrorLogs);
|
||||
wireModal(modal, closeBtn);
|
||||
})();
|
||||
@@ -3,14 +3,18 @@
|
||||
let es = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT = 30000;
|
||||
let _sseFailCount = 0;
|
||||
let _sseManuallyClosed = false;
|
||||
|
||||
function connect() {
|
||||
if (es) { try { es.close(); } catch (_) {} }
|
||||
if (_sseManuallyClosed) return; // auth-lost: don't reconnect
|
||||
|
||||
es = new EventSource('/api/v1/events/stream');
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
reconnectDelay = 1000; // reset backoff
|
||||
_sseFailCount = 0; // reset failure counter
|
||||
debug('[SSE] Connected to event stream');
|
||||
});
|
||||
|
||||
@@ -101,15 +105,46 @@
|
||||
// Reconnect on error
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
// If auth was explicitly lost (401/403 from the polling loop),
|
||||
// don't attempt reconnection at all.
|
||||
if (window._dcAuthLost || _sseManuallyClosed) {
|
||||
console.warn('[SSE] Auth lost — stopping reconnection');
|
||||
return;
|
||||
}
|
||||
// Transient failures: retry with exponential backoff, stop after 5
|
||||
_sseFailCount++;
|
||||
if (_sseFailCount > 5) {
|
||||
console.warn('[SSE] Max reconnect attempts reached — stopping (server unreachable)');
|
||||
return;
|
||||
}
|
||||
console.warn(`[SSE] Disconnected, reconnecting in ${reconnectDelay / 1000}s...`);
|
||||
setTimeout(connect, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT);
|
||||
};
|
||||
}
|
||||
|
||||
// Called by grid.js when the polling loop detects auth loss (401/403)
|
||||
function closeAndStop() {
|
||||
_sseManuallyClosed = true;
|
||||
if (es) { try { es.close(); } catch (_) {} }
|
||||
}
|
||||
|
||||
// Called by totp-auth.js after a successful mid-session re-auth:
|
||||
// clears the latch so connect() can proceed again and resets the
|
||||
// failure backoff. (Plain _sseReconnect/connect() would early-return
|
||||
// on the latch forever — the user would need a manual F5.)
|
||||
function resumeAfterReauth() {
|
||||
_sseManuallyClosed = false;
|
||||
_sseFailCount = 0;
|
||||
reconnectDelay = 1000;
|
||||
connect();
|
||||
}
|
||||
|
||||
// Start on page load
|
||||
connect();
|
||||
|
||||
// Expose for debugging
|
||||
// Expose for debugging and cross-module coordination
|
||||
window._sseReconnect = connect;
|
||||
window._sseClose = closeAndStop;
|
||||
window._sseResume = resumeAfterReauth;
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -125,6 +125,15 @@
|
||||
if (typeof window.initializeDashboard === 'function') {
|
||||
window.initializeDashboard();
|
||||
}
|
||||
// Resume live updates after mid-session re-auth. The auth-loss
|
||||
// handlers latched polling + SSE off when the session expired
|
||||
// (grid.js sets _dcAuthLost, live-events.js latches the stream
|
||||
// closed); a fresh login must clear both and reconnect, or the
|
||||
// dashboard stays frozen on stale data until a manual F5.
|
||||
window._dcAuthLost = false;
|
||||
if (typeof window._sseResume === 'function') window._sseResume();
|
||||
else if (typeof window._sseReconnect === 'function') window._sseReconnect();
|
||||
if (typeof window.refreshAll === 'function') window.refreshAll();
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Invalid code';
|
||||
errorEl.className = 'totp-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>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-4a75cb88af';
|
||||
const CACHE = 'dashcaddy-shell-78eab743c2';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user