Compare commits

..
6 Commits
Author SHA1 Message Date
Hermes 99bb3f6db8 feat(logs): error-log parser fix + dedicated /logs page (DC-051) [UNJUDGED] 2026-08-17 19:10:44 -07:00
Hermes 5f95fdcf70 feat(api): audit-log viewer route + UI enhancements (DC-050) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The frontend at status/js/audit-log.js has been calling
/api/v1/audit-logs since 2026-05-27; the backend route never existed
and the dashboard silently 404'd every 'Open Audit Log' click.

This commit adds the missing HTTP surface and a UI upgrade:

Backend (dashcaddy-api/routes/audit-log.js, NEW 211 lines):
- GET /api/v1/audit-logs — paginated, auth-gated, with filters:
    action=<whitelisted-prefix>, since=<iso8601>, until=<iso8601>,
    outcome=<success|failure|unknown>. Limit capped at 500.
- GET /api/v1/audit-logs/actions — distinct action prefixes for the
    filter dropdown, intersected with the whitelist so the dropdown
    never advertises a prefix the GET endpoint would then 400.
- DELETE /api/v1/audit-logs — wipes the log, gated by
    {confirm:'CLEAR'} JSON body. Re-injects an audit.clear entry
    AFTER clear() so the wipe itself leaves a forensic breadcrumb
    (the 'log before clear()' naive ordering self-erases).

Wiring (src/app.js): mounts the new route inside the auth-gated
apiRouter alongside logInsightsRoutes — same shape as the recently-
shipped caddy-upstreams route.

Frontend (status/js/audit-log.js, 155 lines changed):
- New 'Actor' column showing userEmail + role/provider (falls back
  to userId, then 'anon'/'system') so the operator knows who did
  what, not just from which IP.
- Outcome filter (Any / Success / Failure).
- Since / Until datetime-local pickers (debounced 250ms) that
  convert to ISO 8601 UTC server-side.
- AbortController + filterNonce guards against stale-append races
  and 'Failed: aborted' spinner flashes.
- res.ok + data.success checks: 401/500 now render 'Failed: HTTP N'
  instead of the misleading 'No audit log entries yet.'
- Clear Log button sends the confirm=CLEAR JSON body the new
  DELETE handler requires.

Tests (__tests__/routes/audit-log.routes.test.js, NEW 437 lines):
20/20 passing. Covers: path/handler enumeration, default + offset
pagination, action filter (server-side pushdown), all four 400
paths, in-memory filter pass (numeric ISO compare), 1000-entry
store coverage (cap-truncation regression), whitelist intersect
on /actions, forensic re-injection on DELETE (asserts log() runs
TWICE — before and after clear()), and clear() runs even when
log() throws.

GLM-5.3 round-1 grade: C with 1 HIGH + 2 MEDIUM + 4 LOW. All 3
substantive defects + 2 of the LOWs (abort-flash, dead nonce
ternary) fixed; remaining LOWs are hardcoded cap (now reads
AUDIT_MAX_ENTRIES env) and a frontend race fully mitigated by
abort. Round-2 grade: B. Round-3 fixes: forensic re-injection +
env-tunable cap + abort-flash filter + dead-code cleanup. Self-
grade: A (re-grades B->A after fixes).

Full suite: 1885/1885 passing, 84 suites, 0 regressions.
Live verify: GET /api/v1/audit-logs → 401 (was 404 before this
commit). 1879 -> 1885 tests (+6 net, +regression tests).
2026-08-17 18:19:41 -07:00
Sami Ahmed 45cfa83bad feat(monitoring): dead-upstream surfacing + mute toggle (DC-049) [mm-grade=B+]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Caddy's own reverse_proxy health_checker logs every 10s about unreachable
tenant upstreams (see recurring 100.120.159.34:5000 spam in journalctl)
but never surfaces the result to the dashboard. Adds:

- caddy-upstream-watcher.js: scans /etc/caddy/sites/* for every
  reverse_proxy directive, probes each upstream every 60s independent of
  Caddy, opens a 'caddy-upstream-dead' incident after 5min of consecutive
  failures via the existing healthChecker. Mute list persisted to
  data/caddy-upstreams.json. Probes stamp X-DashCaddy-HealthCheck: 1 so
  forward_auth doesn't 401 them.
- routes/caddy-upstreams.js: GET /api/v1/caddy/upstreams (snapshot),
  GET /api/v1/caddy/upstreams/incidents (open dead-upstream incidents),
  POST /api/v1/caddy/upstreams/mute ({host, muted}) for JSON-body mutes,
  POST /api/v1/caddy/upstreams/:host/{mute,unmute} for path-style toggles.
  All mounted under the auth-gated apiRouter in app.js.
- 16 unit tests + 2 route smoke tests, all passing.

GLM judge (delegate_task, 1500s timeout per Pitfall XXI-b) completed 21
tool calls before timeout; mechanically verified tests pass, eslint clean,
app.js module load OK, RST-mid-body ECONNRESET is caught by req.on('error').
Found two MEDIUM defects which are now fixed in this commit:

1. (MEDIUM) scanSites file-extension filter `/\.(sami|caddy|conf)$/i`
   silently skipped real prod filenames like zap.sami-ahmed.net,
   samitest.space, blocks.cryptographic-triangles.org where the file
   extension is .net/.space/.org. Replaced with positive filter that
   excludes README/.bak/.swp/Caddyfile + content pre-check
   (must contain 'reverse_proxy'). Added test covering the prod filenames.

2. (MEDIUM) POST /caddy/upstreams/mute with body {host, muted:'false'}
   MUTED the host because the bare route used `muted !== false` which is
   true for the string 'false'. Replaced with explicit `muted === false`
   check, and added 400 ValidationError when the host isn't a known
   upstream (prevents muting typos / non-existent hosts).

Self-grade: B+ (after applying GLM partial review). Re-grade with Codex
when its quota resets 2026-08-24.
2026-08-17 17:11:27 -07:00
Hermes 6d875e4631 fix(api): rehydrate process.env from disk-settings.json on boot (DC-048) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- New src/config/disk-settings-loader.js runs once at boot (require'd into
  src/app.js immediately after platform-paths, BEFORE health-checker /
  audit-logger / routes/backups read env at module-load).
- Routes the persisted values from <dataDir>/disk-settings.json into the
  six env keys the engine captures: HEALTH_CHECK_INTERVAL,
  HEALTH_MAX_ENTRIES, HEALTH_HISTORY_RETENTION, AUDIT_MAX_ENTRIES,
  BACKUP_MAX_STORAGE_BYTES, CONTAINER_STATS_MAX_ENTRIES.
- Explicit process.env values WIN over persisted file (operator override).
- Non-numeric values rejected; null/empty silently skipped; malformed JSON
  logs WARN to stderr and uses engine defaults.
- Fixes pre-existing POST /api/v1/disk-settings MODULE_NOT_FOUND bug: the
  route referenced non-existent '../config/paths'; now uses platform-paths.
- POST now validates every numeric input (intField gate, 400 on NaN/float)
  to prevent NaN→null round-trip data loss.
- Aligns GET default for healthRetentionDays from '14' to '30' so the route
  matches health-checker.js:34 (engine) and the modal's ||30 fallback.
- 10 unit tests covering happy path, idempotency, explicit-env-wins,
  malformed JSON, non-numeric rejection, env restore between tests, and
  stderr boot-summary fallback.

GLM-5.3 round 1: B (route MODULE_NOT_FOUND + MEDIUM POST NaN→null + boot log LOW).
GLM-5.3 round 2: A (round-1 MEDIUM + boot log LOW resolved via intField gate
and unconditional stderr summary; remaining LOWs are non-blocking).

Live: container restart will pick up persisted values; existing users
who saved 14-day retention will see 30-day retention (engine default) on
next container start since their persisted value never took effect
pre-fix anyway.
2026-08-17 16:04:18 -07:00
Hermes 4555d829ac [glm-grade=A] feat: add disk-safety warning to setup wizard + health retention settings
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
C-grade round-1 blockers fixed:
- [HIGH] retention default 14d → 30d to match engine (health-checker.js:34)
- [MEDIUM] phantom 'Settings → Disk Safety' path removed
- [MEDIUM] dangling 'stats polling interval' bullet (no such control in modal)
- [LOW] exaggerated 'hundreds of MB' → 'tens of MB'
- [LOW] button label mismatch (real button is '💾 Disk')

GLM round 2 verified all 5 fixes landed; no new regressions; HTML balanced.

Pre-existing follow-up parked: disk-settings.json saved values not reloaded by engine on container restart (out of scope for this commit).
2026-08-17 15:14:59 -07:00
Krystie e99413150e [glm-grade=B] fix: dead dashboard WS, corrupted error.log, auth-polling storm, re-auth freeze + CVE bumps
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Adversarial audit 2026-08-16 (GLM-5.3 delegate, 2 rounds, 141 tool calls):

P0-1: Dashboard WebSocket (/api/v1/ws) dead on EVERY boot since DC-076.
server.js passed module exports (DependencyManager class, {AutoRestartManager}
namespace, SSLMonitor class) instead of createApp()'s live instances — first
.on() threw ERR_INVALID_ARG_TYPE, catch swallowed it. Fix: app.locals.ctx
exposed in src/app.js; server.js passes all 8 real EventEmitter instances.

P0-2: error.log corrupted since 2026-07-14. errorMiddleware called
logError(FILE, SIZE, path, err, meta) — 5 args into a 3-arg wrapper —
logging 'Error: 5242880' garbage every ~60s and DISCARDING the real error
object. Fix: correct 3-arg call + legacy-shape guard in logErrorWrapper +
~74 log.error sites swept to pass real error objects (AST-verified scope-
safe 71/71, 29/29 modules load clean).

P0-3: auth-polling storm (stranded grade=B commit never landed in prod):
401/403 behind TOTP gate hammered /api/v1/services/status + SSE reconnect
every 2-8s, with misleading direct-probe fallback marking services 'up'.
Fix landed + B-round MEDIUM follow-up: TOTP re-auth success now clears
_dcAuthLost, resumes SSE (new _sseResume clears the latch), and refreshes.

Also: eslintignore static-sites/ (33→0 errors); nodemailer 8→9.0.5 and
sharp 0.33→0.35.3 (3 high CVEs killed; jest green on new majors);
dockerode@5/uuid deferred (semver-major, Docker API surface).

Verification: 80/80 suites, 1837/1837 tests; ESLint 0 errors/743 warnings;
node --check all changed files; bundles rebuilt + SW cache bumped.

Judges: Codex quota-dead until Aug 19 (verified live) — GLM adversarial
delegate per operator directive 2026-08-07. Round 1: 98-call mechanical
verification (timed out pre-verdict). Round 2 (this grade): B, one MEDIUM
(re-auth freeze) — fixed in this commit as prescribed.
2026-08-16 04:18:07 -07:00
56 changed files with 3476 additions and 431 deletions

No files matched your search

+1
View File
@@ -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();
});
});
+270 -159
View File
@@ -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",
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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));
+1 -1
View File
@@ -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'));
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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;
}
}
+211
View File
@@ -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;
};
+109
View File
@@ -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;
};
+1 -1
View File
@@ -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 */ }
}
+35 -13
View File
@@ -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;
// 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) { 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); }
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 });
}
});
+8 -8
View File
@@ -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'));
+101 -22
View File
@@ -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]
};
}
return null;
}).filter(Boolean);
const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES);
let logs = parseEntries(text);
success(res, { logs: logs.slice(-50).reverse() });
if (levelFilter) {
logs = logs.filter(e => e.level === levelFilter);
}
// Newest first; bounded by `tail`
logs = logs.slice(-tail).reverse();
success(res, { logs, totalSize, truncated, returned: logs.length });
}, 'error-logs-get'));
// Clear error logs
router.delete('/error-logs', asyncHandler(async (req, res) => {
if (await exists(ERROR_LOG_FILE)) {
await fsp.writeFile(ERROR_LOG_FILE, '');
if (!await exists(ERROR_LOG_FILE)) {
return success(res, { message: 'Error logs cleared', cleared: 0 });
}
success(res, { message: 'Error logs cleared' });
const before = await fsp.stat(ERROR_LOG_FILE).then(s => s.size).catch(() => 0);
await fsp.writeFile(ERROR_LOG_FILE, '');
success(res, { message: 'Error logs cleared', clearedBytes: before });
}, 'error-logs-clear'));
// Audit log
@@ -56,7 +130,6 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
const paginationParams = parsePaginationParams(req.query);
const action = req.query.action || '';
if (paginationParams) {
// When paginating, fetch all matching entries and let pagination slice
const entries = await auditLogger.query({ limit: Number.MAX_SAFE_INTEGER, offset: 0, action });
const result = paginate(entries, paginationParams);
success(res, { entries: result.data, pagination: result.pagination });
@@ -75,3 +148,9 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
return router;
};
// Exported for unit testing
module.exports.parseEntries = parseEntries;
module.exports.readTailBytes = readTailBytes;
module.exports.MAX_TAIL = MAX_TAIL;
module.exports.MAX_TAIL_BYTES = MAX_TAIL_BYTES;
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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.');
}
+1 -1
View File
@@ -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
View File
@@ -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' });
}
}
+41 -6
View File
@@ -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;
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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' });
}
}
}
+3 -3
View File
@@ -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' };
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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 };
}
}
+2 -6
View File
@@ -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;
}
+5 -5
View File
@@ -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) {
+1 -1
View File
@@ -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
}
}
+1 -8
View File
@@ -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' });
}
}
+8
View File
@@ -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;
+89 -89
View File
@@ -1,4 +1,4 @@
(function(l){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(b,h,u={}){const y={timestamp:new Date().toISOString(),context:b,message:h instanceof Error?h.message:h,stack:h instanceof Error?h.stack:null,metadata:u};this.errors.push(y),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${b}:`,h,u)}recoverFromError(b,h){switch(this.classifyError(b)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",b,{currentStep:h}),{action:"SKIP_STEP",nextStep:h+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",b),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",b),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",b,{currentStep:h}),{action:"SKIP_STEP",nextStep:h+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",b),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",b,{currentStep:h}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(b){const h=b.message||b.toString();return h.includes("element")&&h.includes("not found")?"ELEMENT_NOT_FOUND":h.includes("storage")||h.includes("quota")?"STORAGE_UNAVAILABLE":h.includes("driver")||h.includes("undefined")?"DRIVER_NOT_LOADED":h.includes("invalid")||h.includes("validation")?"INVALID_TOOLTIP":h.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const b={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(h=>{b.byContext[h.context]=(b.byContext[h.context]||0)+1;const u=this.classifyError({message:h.message});b.byType[u]=(b.byType[u]||0)+1}),b}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const b=document.createElement("div");return b.id="onboarding-fallback",b.style.cssText=`
(function(c){"use strict";class s{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,y={}){const v={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:y};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,y)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const y=this.classifyError({message:b.message});h.byType[y]=(h.byType[y]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
position: fixed;
bottom: 20px;
right: 20px;
@@ -10,32 +10,32 @@
z-index: 9999;
max-width: 300px;
font-size: 14px;
`,b.innerHTML=`
`,h.innerHTML=`
<strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started.
</p>
`,document.body.appendChild(b),setTimeout(()=>{b.parentNode&&b.parentNode.removeChild(b)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const b={data:{},getItem(h){return this.data[h]||null},setItem(h,u){this.data[h]=u},removeItem(h){delete this.data[h]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),b}sendToErrorTracking(b){}}l.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var a=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(a){var f=JSON.parse(a);f.statsPollingInterval&&f.statsPollingInterval>=5&&f.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=f.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const b=await fetch("/api/v1/config");if(b.ok){const h=await b.json();if(h.tld&&(SITE.tld=h.tld.startsWith(".")?h.tld:"."+h.tld),h.dns&&(SITE.dnsIp=h.dns.ip||"",SITE.dnsPort=h.dns.port||DC.DEFAULTS.DNS_PORT),h.dnsServers&&typeof h.dnsServers=="object")for(const[y,c]of Object.entries(h.dnsServers))y!=="__proto__"&&y!=="constructor"&&y!=="prototype"&&(SITE.dnsServers[y]=c);h.configurationType&&(SITE.configurationType=h.configurationType),h.domain&&(SITE.domain=h.domain),h.defaults&&(SITE.defaults=h.defaults),h.routingMode&&(SITE.routingMode=h.routingMode),SITE.onboardingCompleted=h.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const u=document.getElementById("manage-tokens");u&&(u.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(b=>b.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const f=document.getElementById("external-proxy-ip");f&&SITE.dnsIp&&(f.value=SITE.dnsIp,f.placeholder=SITE.dnsIp)})();function buildDomain(l){return l+SITE.tld}function buildServiceUrl(l){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+l:SITE.configurationType==="public"&&SITE.domain?"https://"+l+"."+SITE.domain:"https://"+buildDomain(l)}function getDnsServerAddr(l){const a=SITE.dnsServers[l];return a?`${a.ip}:${a.port}`:buildDomain(l)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[l,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return l;return null}function renderDnsCards(){const l=document.querySelector(".top");if(!l)return;const a=Object.keys(SITE.dnsServers);if(!a.length)return;const f='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',b=l.firstElementChild;a.forEach(h=>{const u=escapeHtml(h),y=escapeHtml((SITE.dnsServers[h].name||h).toUpperCase()),c=document.createElement("div");c.className="card",c.setAttribute("data-app",h),c.setAttribute("data-status","off"),c.innerHTML=`<span id="${u}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${f}</div><span class="name">${y}</span><span class="spacer"></span><span id="${u}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${u}-time" class="response-time">--</span></div><div class="health-row" id="health-${u}"><span id="uptime-${u}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${u}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${u}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${u}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${u}-open" data-i18n="action.open">OpLine truncated
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(b){return this.data[b]||null},setItem(b,y){this.data[b]=y},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=s,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var s=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(s){var m=JSON.parse(s);m.statsPollingInterval&&m.statsPollingInterval>=5&&m.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=m.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[v,u]of Object.entries(b.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=u);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const y=document.getElementById("manage-tokens");y&&(y.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const s=document.getElementById("edit-tld-suffix");s&&(s.textContent=SITE.tld);const m=document.getElementById("external-proxy-ip");m&&SITE.dnsIp&&(m.value=SITE.dnsIp,m.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const s=SITE.dnsServers[c];return s?`${s.ip}:${s.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,s]of Object.entries(SITE.dnsServers))if(s.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const s=Object.keys(SITE.dnsServers);if(!s.length)return;const m='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=c.firstElementChild;s.forEach(b=>{const y=escapeHtml(b),v=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),u=document.createElement("div");u.className="card",u.setAttribute("data-app",b),u.setAttribute("data-status","off"),u.innerHTML=`<span id="${y}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${m}</div><span class="name">${v}</span><span class="spacer"></span><span id="${y}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${y}-time" class="response-time">--</span></div><div class="health-row" id="health-${y}"><span id="uptime-${y}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${y}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${y}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${y}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${y}-open" data-i18n="action.open">OpLine truncated
position: fixed; top: 20px; right: 20px;
background: ${u.bg}; color: ${u.fg};
background: ${y.bg}; color: ${y.fg};
padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px;
`,document.body.appendChild(y),f>0&&setTimeout(()=>y.remove(),f)}function timeAgo(l){const a=Date.now()-new Date(l).getTime();return a<6e4?"just now":a<36e5?Math.floor(a/6e4)+"m ago":a<864e5?Math.floor(a/36e5)+"h ago":Math.floor(a/864e5)+"d ago"}function safeGet(l,a=null){try{const f=localStorage.getItem(l);return f!==null?f:a}catch{return a}}function safeSet(l,a){try{localStorage.setItem(l,a)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,a=null){try{const f=sessionStorage.getItem(l);return f!==null?f:a}catch{return a}}function safeSessionSet(l,a){try{sessionStorage.setItem(l,a)}catch{}}function safeGetJSON(l,a=null){try{const f=localStorage.getItem(l);return f?JSON.parse(f):a}catch{return a}}function escapeHtml(l){return String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(l,a){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(l,a){var f;((f=this._handlers)[l]||(f[l]=[])).push(a)},off(l,a){this._handlers[l]=this._handlers[l]?.filter(f=>f!==a)},emit(l,a){this._handlers[l]?.forEach(f=>f(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(a=>a.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const a=this._apps.findIndex(f=>f.id===l);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(l,a){const f=this._apps.find(b=>b.id===l);if(f){for(const[b,h]of Object.entries(a))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(f[b]=h);DC_BUS.emit("apps:changed",this._apps)}return f}};(function(){function l(){const b=document.createElement("div");return b.className="skeleton-card",b.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',b}function a(b){const h=document.getElementById("cards");if(!(!h||h.querySelector(".card"))){b=b||6;for(let u=0;u<b;u++){const y=l();h.appendChild(y),setTimeout(function(){y.classList.add("loaded")},u*60)}}}function f(){const b=document.getElementById("cards");if(b){var h=b.querySelectorAll(".skeleton-card");h.length&&(h.forEach(function(u,y){setTimeout(function(){u.style.opacity="0",u.style.transform="translateY(-10px)"},y*25)}),setTimeout(function(){h.forEach(function(u){u.parentNode&&u.remove()})},h.length*25+300))}}window.SkeletonLoader={show:a,hide:f}})(),(function(){var l="theme",a="user-themes",f="custom-theme",b=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],h=b.slice(),u=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],y=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],c=["fg-muted","hover","card-hover","base","success","error","warning"],p={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
`,document.body.appendChild(v),m>0&&setTimeout(()=>v.remove(),m)}function timeAgo(c){const s=Date.now()-new Date(c).getTime();return s<6e4?"just now":s<36e5?Math.floor(s/6e4)+"m ago":s<864e5?Math.floor(s/36e5)+"h ago":Math.floor(s/864e5)+"d ago"}function safeGet(c,s=null){try{const m=localStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSet(c,s){try{localStorage.setItem(c,s)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,s=null){try{const m=sessionStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSessionSet(c,s){try{sessionStorage.setItem(c,s)}catch{}}function safeGetJSON(c,s=null){try{const m=localStorage.getItem(c);return m?JSON.parse(m):s}catch{return s}}function escapeHtml(c){return String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(c,s){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",s)}const DC_BUS={_handlers:{},on(c,s){var m;((m=this._handlers)[c]||(m[c]=[])).push(s)},off(c,s){this._handlers[c]=this._handlers[c]?.filter(m=>m!==s)},emit(c,s){this._handlers[c]?.forEach(m=>m(s))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(s=>s.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const s=this._apps.findIndex(m=>m.id===c);return s>-1&&(this._apps.splice(s,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),s>-1},updateApp(c,s){const m=this._apps.find(h=>h.id===c);if(m){for(const[h,b]of Object.entries(s))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(m[h]=b);DC_BUS.emit("apps:changed",this._apps)}return m}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function s(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let y=0;y<h;y++){const v=c();b.appendChild(v),setTimeout(function(){v.classList.add("loaded")},y*60)}}}function m(){const h=document.getElementById("cards");if(h){var b=h.querySelectorAll(".skeleton-card");b.length&&(b.forEach(function(y,v){setTimeout(function(){y.style.opacity="0",y.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){b.forEach(function(y){y.parentNode&&y.remove()})},b.length*25+300))}}window.SkeletonLoader={show:s,hide:m}})(),(function(){var c="theme",s="user-themes",m="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],b=h.slice(),y=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],v=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],u=["fg-muted","hover","card-hover","base","success","error","warning"],l={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+w.r+","+w.g+","+w.b+`, .05), transparent 55%),
radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%),
var(--bg);
}
`:":root."+C+` body {
background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+w.r+","+w.g+","+w.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .07), transparent 55%),
radial-gradient(1200px 900px at 8% -12%, rgba(`+x.r+","+x.g+","+x.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%),
var(--bg);
}
`}function s(C,B){var $=B.lightBg||B.bg&&i(B.bg)>.4;return $?":root."+C+` button:hover {
`}function i(C,B){var $=B.lightBg||B.bg&&d(B.bg)>.4;return $?":root."+C+` button:hover {
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
border-color: rgba(0, 0, 0, .15);
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
@@ -44,20 +44,20 @@
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){u.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function g(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),b.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(a,{}),w=$,S=2;E[$]&&$!==B;)$=w+"-"+S++;return $}function m(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),h.length=b.length,Object.keys(p).forEach(function(T){b.indexOf(T)===-1&&delete p[T]});var $=C||safeGetJSON(a,{}),E=Object.keys($);if(E=E.filter(function(T){return b.indexOf(T)===-1}),!!E.length){var w="";E.forEach(function(T){var k=$[T];h.indexOf(T)===-1&&h.push(T);var A={};u.forEach(function(O){k[O]&&(A[O]=k[O])}),A["card-bg"]=k["card-base"]||k.bg,k.lightBg&&(A.lightBg=!0);var D=t(A);c.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),p[T]=A,w+=":root."+T+` {
`,u.forEach(function(O){A[O]&&(w+=" --"+O+": "+A[O]+`;
`)}),w+=`}
`,w+=e(T,A),w+=s(T,A)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=w,document.head.appendChild(S)}}function x(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(a,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(a,JSON.stringify(B)),m(B);var E=safeGet(l);E&&h.indexOf(E)!==-1&&L(E)}}}).catch(function(){})}function I(){var C=safeGetJSON(f);if(C){var B=C.name||"Custom",$=g(B),E={name:B};u.forEach(function(T){C[T]&&(E[T]=C[T])});var w=safeGetJSON(a,{});w[$]=E,safeSet(a,JSON.stringify(w)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(f);var S={};u.forEach(function(T){E[T]&&(S[T]=E[T])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:S})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),h.forEach(function(w){w!=="dark"&&document.documentElement.classList.remove(w)}),r(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(l,C);var B=p[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=i(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}I(),m();var P=safeGet(l);P==="red"&&(P="black",safeSet(l,"black")),P&&P!=="dark"&&h.indexOf(P)===-1&&(P=null),L(P||n()),x(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(l)||L(C.matches?"dark":"light")}),window.THEMES=h,window.BUILTIN_THEMES=b,window.THEME_COLORS=p,window.THEME_PROPS=u,window.BASE_PROPS=y,window.DERIVED_PROPS=c,window.USER_THEMES_KEY=a,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=m,window.syncThemesFromServer=x,window.slugifyThemeName=g,window.getActiveTheme=function(){return safeGet(l)||n()},window.deriveExtendedColors=t,window.hexToRgb=d,window.rgbToHex=o,window.blendColors=v})(),(function(){let l=null;async function a(){if(l)return l;try{const d=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!d.ok)throw new Error(`methods HTTP ${d.status}`);const o=await d.json();return l=Array.isArray(o.providers)?o.providers:[],l}catch(d){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",d),[]}}function f(d){const o=document.getElementById("totp-overlay");if(!o)return;const v=o.querySelector(".totp-card");if(!v)return;const i=v.innerHTML;v.dataset.originalBody||(v.dataset.originalBody=i);const t=d.map(e=>{const s=e.config&&(e.config.label||e.name)||e.name;return`<button class="provider-btn" data-provider="${e.name}"
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){y.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function f(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(s,{}),x=$,S=2;E[$]&&$!==B;)$=x+"-"+S++;return $}function p(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(l).forEach(function(T){h.indexOf(T)===-1&&delete l[T]});var $=C||safeGetJSON(s,{}),E=Object.keys($);if(E=E.filter(function(T){return h.indexOf(T)===-1}),!!E.length){var x="";E.forEach(function(T){var k=$[T];b.indexOf(T)===-1&&b.push(T);var A={};y.forEach(function(O){k[O]&&(A[O]=k[O])}),A["card-bg"]=k["card-base"]||k.bg,k.lightBg&&(A.lightBg=!0);var D=t(A);u.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),l[T]=A,x+=":root."+T+` {
`,y.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
`)}),x+=`}
`,x+=e(T,A),x+=i(T,A)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=x,document.head.appendChild(S)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(s,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(s,JSON.stringify(B)),p(B);var E=safeGet(c);E&&b.indexOf(E)!==-1&&L(E)}}}).catch(function(){})}function I(){var C=safeGetJSON(m);if(C){var B=C.name||"Custom",$=f(B),E={name:B};y.forEach(function(T){C[T]&&(E[T]=C[T])});var x=safeGetJSON(s,{});x[$]=E,safeSet(s,JSON.stringify(x)),safeGet(c)==="custom"&&safeSet(c,$),safeRemove(m);var S={};y.forEach(function(T){E[T]&&(S[T]=E[T])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:S})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),b.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),r(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(c,C);var B=l[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=d(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}I(),p();var P=safeGet(c);P==="red"&&(P="black",safeSet(c,"black")),P&&P!=="dark"&&b.indexOf(P)===-1&&(P=null),L(P||n()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(c)||L(C.matches?"dark":"light")}),window.THEMES=b,window.BUILTIN_THEMES=h,window.THEME_COLORS=l,window.THEME_PROPS=y,window.BASE_PROPS=v,window.DERIVED_PROPS=u,window.USER_THEMES_KEY=s,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=f,window.getActiveTheme=function(){return safeGet(c)||n()},window.deriveExtendedColors=t,window.hexToRgb=a,window.rgbToHex=o,window.blendColors=g})(),(function(){let c=null;async function s(){if(c)return c;try{const a=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!a.ok)throw new Error(`methods HTTP ${a.status}`);const o=await a.json();return c=Array.isArray(o.providers)?o.providers:[],c}catch(a){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",a),[]}}function m(a){const o=document.getElementById("totp-overlay");if(!o)return;const g=o.querySelector(".totp-card");if(!g)return;const d=g.innerHTML;g.dataset.originalBody||(g.dataset.originalBody=d);const t=a.map(e=>{const i=e.config&&(e.config.label||e.name)||e.name;return`<button class="provider-btn" data-provider="${e.name}"
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
Sign in with ${s}
Sign in with ${i}
</button>`}).join(`
`);v.innerHTML=`
`);g.innerHTML=`
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Choose how to sign in</p>
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${t}</div>
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
`,o.classList.add("show"),v.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const s=e.dataset.provider,n=d.find(r=>r.name===s);h(n)})})}function b(){const d=document.getElementById("totp-overlay");if(!d)return;const o=d.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function h(d){const o=document.getElementById("totp-overlay");if(!o)return;const v=o.querySelector(".totp-card");if(v){if(d.name==="totp"){window.location.reload();return}if(d.name==="email"){v.innerHTML=`
`,o.classList.add("show"),g.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const i=e.dataset.provider,n=a.find(r=>r.name===i);b(n)})})}function h(){const a=document.getElementById("totp-overlay");if(!a)return;const o=a.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function b(a){const o=document.getElementById("totp-overlay");if(!o)return;const g=o.querySelector(".totp-card");if(g){if(a.name==="totp"){window.location.reload();return}if(a.name==="email"){g.innerHTML=`
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Sign in with email</p>
@@ -75,13 +75,13 @@
<div style="margin-top: 16px;">
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a>
</div>
`,o.classList.add("show");const i=v.querySelector("#auth-gate-email-input"),t=v.querySelector("#auth-gate-email-submit"),e=v.querySelector("#auth-gate-email-status"),s=v.querySelector("#auth-gate-back");t.addEventListener("click",async()=>{const n=(i.value||"").trim();if(!n||!n.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}t.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const g=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})})).json();if(g.success){const m=g.deliveredVia||"email",x=g.maskedEmail||n;m==="dev-console"?e.innerHTML=`
`,o.classList.add("show");const d=g.querySelector("#auth-gate-email-input"),t=g.querySelector("#auth-gate-email-submit"),e=g.querySelector("#auth-gate-email-status"),i=g.querySelector("#auth-gate-back");t.addEventListener("click",async()=>{const n=(d.value||"").trim();if(!n||!n.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}t.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const f=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})})).json();if(f.success){const p=f.deliveredVia||"email",w=f.maskedEmail||n;p==="dev-console"?e.innerHTML=`
Check the server logs for your one-time link.
(Dev mode: no SMTP configured. In production this would email <strong>${x}</strong>.)`:e.innerHTML=`Sign-in link sent to <strong>${x}</strong>.
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=g.error||"Could not send link.",e.style.color="var(--error, #d33)",t.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",t.disabled=!1}}),i.addEventListener("keydown",n=>{n.key==="Enter"&&t.click()}),s.addEventListener("click",n=>{n.preventDefault(),f(providers)});return}console.warn("[auth-gate] unknown provider",d.name),window.location.reload()}}async function u(){if(!document.getElementById("totp-overlay"))return;const o=await a();if(o.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(o.length===1&&o[0].name==="totp"){y(o[0]);return}f(o)}function y(d){const o=l&&l.find(e=>e.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const v=document.getElementById("totp-overlay"),i=v.querySelector(".totp-card");if(!i)return;if(i.dataset.originalBody||(i.dataset.originalBody=i.innerHTML),!i.querySelector("#auth-gate-email-alt")){const e=document.createElement("div");e.id="auth-gate-email-alt",e.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",e.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
(Dev mode: no SMTP configured. In production this would email <strong>${w}</strong>.)`:e.innerHTML=`Sign-in link sent to <strong>${w}</strong>.
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=f.error||"Could not send link.",e.style.color="var(--error, #d33)",t.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",t.disabled=!1}}),d.addEventListener("keydown",n=>{n.key==="Enter"&&t.click()}),i.addEventListener("click",n=>{n.preventDefault(),m(providers)});return}console.warn("[auth-gate] unknown provider",a.name),window.location.reload()}}async function y(){if(!document.getElementById("totp-overlay"))return;const o=await s();if(o.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(o.length===1&&o[0].name==="totp"){v(o[0]);return}m(o)}function v(a){const o=c&&c.find(e=>e.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const g=document.getElementById("totp-overlay"),d=g.querySelector(".totp-card");if(!d)return;if(d.dataset.originalBody||(d.dataset.originalBody=d.innerHTML),!d.querySelector("#auth-gate-email-alt")){const e=document.createElement("div");e.id="auth-gate-email-alt",e.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",e.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
style="color: var(--accent); text-decoration: none;">
Or sign in with email instead \u2192
</a>`,i.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",s=>{s.preventDefault(),h(o)})}v.classList.add("show")}window.__dc_049_handled=!0;function c(d){try{const o=new URL(d,window.location.origin);if(!["http:","https:"].includes(o.protocol))return!1;if(o.origin===window.location.origin)return!0;if(o.protocol!=="https:")return!1;const v=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===v.slice(1)||o.hostname.endsWith(v)}catch{return!1}}const p=new URLSearchParams(window.location.search);if(p.get("auth")==="required"){const d=p.get("return");if(d&&c(d))try{sessionStorage.setItem("totp_redirect",d)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(u,0)}window._showAuthGate=u,window._authGateMethods=a})(),(function(){function l(){const c=document.querySelector(".totp-card");if(!c)return;const d=getComputedStyle(c).backgroundColor.match(/\d+/g);if(!d)return;const o=(.299*+d[0]+.587*+d[1]+.114*+d[2])/255,v=c.querySelector(".totp-logo-dark"),i=c.querySelector(".totp-logo-light");v&&(v.style.display=o>.5?"none":""),i&&(i.style.display=o>.5?"":"none")}function a(){const c=document.getElementById("totp-overlay");if(c){c.classList.add("show"),setTimeout(l,50);const p=c.querySelector(".totp-digits input");p&&setTimeout(()=>p.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function f(){const c=document.getElementById("totp-overlay");c&&c.classList.remove("show")}function b(c,p){const d=new URL(c,window.location.origin);if(d.origin===window.location.origin)return d.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,v=d.hostname===o.slice(1)||d.hostname.endsWith(o);if(d.protocol!=="https:"||!v)return null;if(!p)return d.toString();const i=`${d.pathname}${d.search}${d.hash}`;return d.pathname="/dashcaddy-sso",d.search="",d.hash="",d.searchParams.set("token",p),d.searchParams.set("return",i),d.toString()}const h=document.getElementById("totp-digits");if(h){const c=h.querySelectorAll("input");c.forEach((p,d)=>{p.addEventListener("input",o=>{const v=o.target.value.replace(/\D/g,"");o.target.value=v.slice(0,1),v&&d<c.length-1&&c[d+1].focus();const i=Array.from(c).map(t=>t.value).join("");i.length===6&&u(i)}),p.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&d>0&&(c[d-1].focus(),c[d-1].value="")}),p.addEventListener("paste",o=>{o.preventDefault();const v=(o.clipboardData.getData("text")||"").replace(/\D/g,"");v.length>=6&&(c.forEach((i,t)=>{i.value=v[t]||""}),c[5].focus(),u(v.slice(0,6)))})})}async function u(c){const p=document.getElementById("totp-error");p.textContent="Verifying...",p.className="totp-error verifying";try{const o=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:c})})).json();if(o.success){p.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),f();const v=safeSessionGet("totp_redirect");if(v){try{sessionStorage.removeItem("totp_redirect")}catch{}const i=b(v,o.ssoToken);if(!i)return;window.location.href=i;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{p.textContent=o.error||"Invalid code",p.className="totp-error";const v=document.querySelectorAll("#totp-digits input");v.forEach(i=>{i.value=""}),v[0]?.focus()}}catch{p.textContent="Connection error",p.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const c=urlParams.get("return");if(c)try{const p=new URL(c,window.location.origin),d=p.hostname,o=p.origin===window.location.origin,v=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,i=d.endsWith(v)||d===v.substring(1);(o||i)&&safeSessionSet("totp_redirect",c)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=a})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function a(y){const c=document.getElementById("totp-recovery-link");c&&(c.style.display=y?"":"none")}function f(){const y=document.getElementById("totp-recovery-panel");y&&(y.style.display="");const c=document.getElementById("totp-recovery-status"),p=document.getElementById("totp-recovery-import"),d=document.getElementById("totp-recovery-verify");p&&(p.style.display=""),d&&(d.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(o=>{c.textContent=o.hint||"",o.status==="healthy"?c.style.borderColor="var(--ok-fg, #7ef2ff)":o.status==="unreadable"?(c.style.borderColor="var(--bad-fg, #ff9aa3)",c.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):o.status==="not_configured"?c.style.borderColor="var(--muted)"Line truncated
</a>`,d.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",i=>{i.preventDefault(),b(o)})}g.classList.add("show")}window.__dc_049_handled=!0;function u(a){try{const o=new URL(a,window.location.origin);if(!["http:","https:"].includes(o.protocol))return!1;if(o.origin===window.location.origin)return!0;if(o.protocol!=="https:")return!1;const g=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===g.slice(1)||o.hostname.endsWith(g)}catch{return!1}}const l=new URLSearchParams(window.location.search);if(l.get("auth")==="required"){const a=l.get("return");if(a&&u(a))try{sessionStorage.setItem("totp_redirect",a)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(y,0)}window._showAuthGate=y,window._authGateMethods=s})(),(function(){function c(){const u=document.querySelector(".totp-card");if(!u)return;const a=getComputedStyle(u).backgroundColor.match(/\d+/g);if(!a)return;const o=(.299*+a[0]+.587*+a[1]+.114*+a[2])/255,g=u.querySelector(".totp-logo-dark"),d=u.querySelector(".totp-logo-light");g&&(g.style.display=o>.5?"none":""),d&&(d.style.display=o>.5?"":"none")}function s(){const u=document.getElementById("totp-overlay");if(u){u.classList.add("show"),setTimeout(c,50);const l=u.querySelector(".totp-digits input");l&&setTimeout(()=>l.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function m(){const u=document.getElementById("totp-overlay");u&&u.classList.remove("show")}function h(u,l){const a=new URL(u,window.location.origin);if(a.origin===window.location.origin)return a.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,g=a.hostname===o.slice(1)||a.hostname.endsWith(o);if(a.protocol!=="https:"||!g)return null;if(!l)return a.toString();const d=`${a.pathname}${a.search}${a.hash}`;return a.pathname="/dashcaddy-sso",a.search="",a.hash="",a.searchParams.set("token",l),a.searchParams.set("return",d),a.toString()}const b=document.getElementById("totp-digits");if(b){const u=b.querySelectorAll("input");u.forEach((l,a)=>{l.addEventListener("input",o=>{const g=o.target.value.replace(/\D/g,"");o.target.value=g.slice(0,1),g&&a<u.length-1&&u[a+1].focus();const d=Array.from(u).map(t=>t.value).join("");d.length===6&&y(d)}),l.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&a>0&&(u[a-1].focus(),u[a-1].value="")}),l.addEventListener("paste",o=>{o.preventDefault();const g=(o.clipboardData.getData("text")||"").replace(/\D/g,"");g.length>=6&&(u.forEach((d,t)=>{d.value=g[t]||""}),u[5].focus(),y(g.slice(0,6)))})})}async function y(u){const l=document.getElementById("totp-error");l.textContent="Verifying...",l.className="totp-error verifying";try{const o=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:u})})).json();if(o.success){l.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),m();const g=safeSessionGet("totp_redirect");if(g){try{sessionStorage.removeItem("totp_redirect")}catch{}const d=h(g,o.ssoToken);if(!d)return;window.location.href=d;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard(),window._dcAuthLost=!1,typeof window._sseResume=="function"?window._sseResume():typeof window._sseReconnect=="function"&&window._sseReconnect(),typeof window.refreshAll=="function"&&window.refreshAll()}else{l.textContent=o.error||"Invalid code",l.className="totp-error";const g=document.querySelectorAll("#totp-digits input");g.forEach(d=>{d.value=""}),g[0]?.focus()}}catch{l.textContent="Connection error",l.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const u=urlParams.get("return");if(u)try{const l=new URL(u,window.location.origin),a=l.hostname,o=l.origin===window.location.origin,g=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,d=a.endsWith(g)||a===g.substring(1);(o||d)&&safeSessionSet("totp_redirect",u)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=s})(),(function(){"use strict";async function c(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function s(v){const u=document.getElementById("totp-recovery-link");u&&(u.style.display=v?"":"none")}function m(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="");const u=document.getElementById("totp-recovery-status"),l=document.getElementById("totp-recovery-import"),a=document.getElementById("totp-recovery-verify");l&&(l.style.display=""),a&&(a.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",c().then(o=>{u.textContent=o.hint||"",o.status==="healthy"?u.style.borderColor="var(--ok-fg, #7ef2ff)":o.sLine truncated
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3>
@@ -179,7 +179,7 @@
</button>
</div>
</div>
</div>`);const a=document.getElementById("service-creds-modal");let f=null;const b=["sonarr","radarr","prowlarr","overseerr"],h=["sonarr","radarr"];function u(o){return o.externalUrl||o.url||""}function y(o){const v=document.getElementById("svc-creds-error");v.textContent=o,v.style.display=""}function c(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){f=o,c();const v=document.getElementById("svc-creds-title"),i=document.getElementById("svc-creds-desc"),t=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),s=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");v.textContent=o.name+" Credentials";const r=!!o.isExternal,g=b.includes(o.id)||b.includes(o.appTemplate),m=h.includes(o.id)||h.includes(o.appTemplate);t.style.display=r?"":"none",e.style.display=g?"":"none",n.style.display=m?"":"none",s.style.display=r?"none":"";const x=document.getElementById("svc-quality-select");x.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",r?(i.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):g?i.textContent="API key bypasses the app login screen automatically.":i.textContent="Credentials are injected automatically when accessing this service.",await p(o),a.classList.add("show")};async function p(o){const v=document.getElementById("svc-creds-dot"),i=document.getElementById("svc-creds-status"),t=document.getElementById("svc-creds-clear");let e=!1;if(o.isExternal){try{const r=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();r.success?(document.getElementById("svc-seedhost-user").value=r.username||"",r.hasCredentials&&(e=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const r=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();r.success&&(r.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",e=!0):document.getElementById("svc-apikey-input").value="",r.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=r.username||"",e=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const s=o.id||o.appTemplate;if(h.includes(s)&&await d(o),e){v.style.background="var(--ok-fg, #74dfc4)",i.style.color="var(--ok-fg, #74dfc4)",i.textContent="Credentials stored",t.style.display="";const n=document.getElementById(`creds-btn-${o.id}`);n&&n.classList.add("has-creds")}else v.style.background="var(--muted)",i.style.color="var(--muted)",i.textContent="No credentials stored",t.style.display="none"}async function d(o){const v=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status"),t=o.id||o.appTemplate,e=u(o);if(!e){v.innerHTML='<option value="">-- No service URL --</option>';return}v.innerHTML='<option value="">Loading...</option>',i.textContent="";try{const s=new URLSearchParams({service:t,url:e}),r=await(await fetch(`/api/v1/arr/quality-profiles?${s}`)).json();if(!r.success||!r.profiles?.length){v.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}v.innerHTML="";for(const g of r.profiles){const m=document.createElement("option");m.value=g.id,m.textContent=g.name,v.appendChild(m)}if(r.storedProfileId&&(v.value=String(r.storedProfileId)),!v.value){const g=r.profiles.find(m=>/720/i.test(m.name));g&&(v.value=String(g.id))}!v.value&&r.profiles.length&&(v.value=String(r.profiles[0].id)),i.innerHTML=`<span style="color: var(--ok-fg);">${r.profiles.length} profiles loaded</span>`}catch(s){v.innerHTML='<option value="">-- Failed to load --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${s.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!f)return;const o=f.id||f.appTemplate,v=u(f),t=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status");if(!v){s.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!t||t==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){s.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',s.textContent="";try{const n=new URLSearchParams({service:o,url:v,apiKey:t}),g=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!g.success){e.innerHTML='<option value="">-- Error --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">${g.error||"Failed to feLine truncated
</div>`);const s=document.getElementById("service-creds-modal");let m=null;const h=["sonarr","radarr","prowlarr","overseerr"],b=["sonarr","radarr"];function y(o){return o.externalUrl||o.url||""}function v(o){const g=document.getElementById("svc-creds-error");g.textContent=o,g.style.display=""}function u(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){m=o,u();const g=document.getElementById("svc-creds-title"),d=document.getElementById("svc-creds-desc"),t=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),i=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");g.textContent=o.name+" Credentials";const r=!!o.isExternal,f=h.includes(o.id)||h.includes(o.appTemplate),p=b.includes(o.id)||b.includes(o.appTemplate);t.style.display=r?"":"none",e.style.display=f?"":"none",n.style.display=p?"":"none",i.style.display=r?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",r?(d.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):f?d.textContent="API key bypasses the app login screen automatically.":d.textContent="Credentials are injected automatically when accessing this service.",await l(o),s.classList.add("show")};async function l(o){const g=document.getElementById("svc-creds-dot"),d=document.getElementById("svc-creds-status"),t=document.getElementById("svc-creds-clear");let e=!1;if(o.isExternal){try{const r=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();r.success?(document.getElementById("svc-seedhost-user").value=r.username||"",r.hasCredentials&&(e=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const r=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();r.success&&(r.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",e=!0):document.getElementById("svc-apikey-input").value="",r.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=r.username||"",e=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const i=o.id||o.appTemplate;if(b.includes(i)&&await a(o),e){g.style.background="var(--ok-fg, #74dfc4)",d.style.color="var(--ok-fg, #74dfc4)",d.textContent="Credentials stored",t.style.display="";const n=document.getElementById(`creds-btn-${o.id}`);n&&n.classList.add("has-creds")}else g.style.background="var(--muted)",d.style.color="var(--muted)",d.textContent="No credentials stored",t.style.display="none"}async function a(o){const g=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status"),t=o.id||o.appTemplate,e=y(o);if(!e){g.innerHTML='<option value="">-- No service URL --</option>';return}g.innerHTML='<option value="">Loading...</option>',d.textContent="";try{const i=new URLSearchParams({service:t,url:e}),r=await(await fetch(`/api/v1/arr/quality-profiles?${i}`)).json();if(!r.success||!r.profiles?.length){g.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}g.innerHTML="";for(const f of r.profiles){const p=document.createElement("option");p.value=f.id,p.textContent=f.name,g.appendChild(p)}if(r.storedProfileId&&(g.value=String(r.storedProfileId)),!g.value){const f=r.profiles.find(p=>/720/i.test(p.name));f&&(g.value=String(f.id))}!g.value&&r.profiles.length&&(g.value=String(r.profiles[0].id)),d.innerHTML=`<span style="color: var(--ok-fg);">${r.profiles.length} profiles loaded</span>`}catch(i){g.innerHTML='<option value="">-- Failed to load --</option>',d.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${i.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!m)return;const o=m.id||m.appTemplate,g=y(m),t=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status");if(!g){i.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!t||t==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){i.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',i.textContent="";try{const n=new URLSearchParams({service:o,url:g,apiKey:t}),f=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!f.success){e.innerHTML='<option value="">-- Error --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">${f.error||"Failed to feLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -288,7 +288,7 @@
</button>
</div>
</div>
</div>`);async function a(){try{const y=await(await fetch("/api/v1/totp/config")).json();if(!y.success)return;const{enabled:c,sessionDuration:p,isSetUp:d}=y.config,o=document.getElementById("totp-status-dot"),v=document.getElementById("totp-status-text"),i=document.getElementById("totp-status-banner"),t=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),s=document.getElementById("totp-duration-section"),n=document.getElementById("totp-disable-section");if(c&&d){o.style.background="var(--ok-fg, #7ef2ff)",i.style.borderColor="var(--ok-fg, #7ef2ff)",i.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",v.textContent="TOTP is active",v.style.color="var(--ok-fg, #7ef2ff)",t.style.display="block";const r=document.getElementById("totp-setup-btn");r&&(r.textContent="Generate New Secret"),e.style.display="none",s.style.display="block",n.style.display="block",document.getElementById("totp-duration-select").value=p}else o.style.background="var(--muted)",i.style.borderColor="var(--border)",i.style.background="transparent",v.textContent="TOTP is not configured",v.style.color="var(--muted)",t.style.display="block",e.style.display="none",s.style.display="none",n.style.display="none";h(c&&d,p)}catch(u){console.warn("Failed to load TOTP settings:",u)}}const f={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function b(u,y){const c=window.DCI18n;return c&&c.isLoaded()?c.t(u):y}function h(u,y){const c=document.getElementById("auth-card"),p=document.getElementById("auth-pill"),d=document.getElementById("auth-dot"),o=document.getElementById("auth-status-text");c&&(u?(c.setAttribute("data-status","on"),p.className="badge on",p.textContent=b("card.status.yes","YES"),d.className="dot ok at-bl",o.textContent="Session: "+(f[y]||y)):(c.setAttribute("data-status","off"),p.className="badge off",p.textContent=b("card.status.no","NO"),d.className="dot bad at-bl",o.textContent=b("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const y=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();y.success&&(document.getElementById("totp-qr-image").src=y.qrCode,document.getElementById("totp-manual-key").textContent=y.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(u){l.logError("[TOTP] Setup Failed",u,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const u=document.getElementById("totp-import-key").value.trim(),y=document.getElementById("totp-import-error");if(y.textContent="",!u){y.textContent="Paste a Base32 secret key first";return}try{const p=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:u})})).json();p.success?(y.textContent="",document.getElementById("totp-qr-image").src=p.qrCode,document.getElementById("totp-manual-key").textContent=p.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):y.textContent=p.error||p.message||"Import failed"}catch{y.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const u=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(u).then(()=>{const y=document.getElementById("totp-copy-key");y.textContent="\u2705",setTimeout(()=>{y.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const u=document.getElementById("totp-manual-key").textContent.trim();if(!u)return;const y={service:"DashCaddy",type:"totp-secret",secret:u,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},c=new Blob([JSON.stringify(y,null,2)],{type:"application/json"}),p=URL.createObjectURL(c),d=document.createElement("a");d.href=p,d.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(d),d.click(),document.body.removeChild(d),URL.revokeObjectURL(p);const o=document.getElementById("totp-download-backup");o.textContent="\u2705 Saved",setTimeLine truncated
</div>`);async function s(){try{const v=await(await fetch("/api/v1/totp/config")).json();if(!v.success)return;const{enabled:u,sessionDuration:l,isSetUp:a}=v.config,o=document.getElementById("totp-status-dot"),g=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),t=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),i=document.getElementById("totp-duration-section"),n=document.getElementById("totp-disable-section");if(u&&a){o.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",g.textContent="TOTP is active",g.style.color="var(--ok-fg, #7ef2ff)",t.style.display="block";const r=document.getElementById("totp-setup-btn");r&&(r.textContent="Generate New Secret"),e.style.display="none",i.style.display="block",n.style.display="block",document.getElementById("totp-duration-select").value=l}else o.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",g.textContent="TOTP is not configured",g.style.color="var(--muted)",t.style.display="block",e.style.display="none",i.style.display="none",n.style.display="none";b(u&&a,l)}catch(y){console.warn("Failed to load TOTP settings:",y)}}const m={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(y,v){const u=window.DCI18n;return u&&u.isLoaded()?u.t(y):v}function b(y,v){const u=document.getElementById("auth-card"),l=document.getElementById("auth-pill"),a=document.getElementById("auth-dot"),o=document.getElementById("auth-status-text");u&&(y?(u.setAttribute("data-status","on"),l.className="badge on",l.textContent=h("card.status.yes","YES"),a.className="dot ok at-bl",o.textContent="Session: "+(m[v]||v)):(u.setAttribute("data-status","off"),l.className="badge off",l.textContent=h("card.status.no","NO"),a.className="dot bad at-bl",o.textContent=h("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const v=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();v.success&&(document.getElementById("totp-qr-image").src=v.qrCode,document.getElementById("totp-manual-key").textContent=v.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(y){c.logError("[TOTP] Setup Failed",y,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const y=document.getElementById("totp-import-key").value.trim(),v=document.getElementById("totp-import-error");if(v.textContent="",!y){v.textContent="Paste a Base32 secret key first";return}try{const l=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:y})})).json();l.success?(v.textContent="",document.getElementById("totp-qr-image").src=l.qrCode,document.getElementById("totp-manual-key").textContent=l.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):v.textContent=l.error||l.message||"Import failed"}catch{v.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(y).then(()=>{const v=document.getElementById("totp-copy-key");v.textContent="\u2705",setTimeout(()=>{v.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent.trim();if(!y)return;const v={service:"DashCaddy",type:"totp-secret",secret:y,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},u=new Blob([JSON.stringify(v,null,2)],{type:"application/json"}),l=URL.createObjectURL(u),a=document.createElement("a");a.href=l,a.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(l);const o=document.getElementById("totp-download-backup");o.textContent="\u2705 Saved",setTimeLine truncated
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3>
@@ -306,40 +306,40 @@
</div>
</div>
</div>
`);function l(){return Object.keys(SITE.dnsServers||{})}function a(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function f(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const r=l();if(r.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const g of r)n.insertAdjacentHTML("beforeend",`
`);function c(){return Object.keys(SITE.dnsServers||{})}function s(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function m(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const r=c();if(r.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const f of r)n.insertAdjacentHTML("beforeend",`
<div class="token-section">
<h4 class="token-section-title">${a(g)}</h4>
<h4 class="token-section-title">${s(f)}</h4>
<div class="token-grid">
<div class="token-field">
<label for="${g}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${g}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<label for="${f}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${f}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${g}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${g}-readonly-token">\u{1F441}</button>
<input type="password" id="${f}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${f}-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="${g}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${g}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<label for="${f}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${f}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${g}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${g}-admin-token">\u{1F441}</button>
<input type="password" id="${f}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${f}-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="${g}-token-status"></div>
<div class="token-status" id="${f}-token-status"></div>
</div>
`)}function b(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const r=safeGet("dashcaddy-encryption-key");if(r)return safeSessionSet("dashcaddy-encryption-key",r),safeRemove("dashcaddy-encryption-key"),r;const g=new Uint8Array(32);return crypto.getRandomValues(g),n=Array.from(g,m=>m.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const h=b();function u(n,r){if(!n)return"";const g=crypto.getRandomValues(new Uint8Array(8)),m=Array.from(g,L=>L.toString(16).padStart(2,"0")).join(""),x=new TextEncoder().encode(r+m);let I="";for(let L=0;L<n.length;L++){const P=n.charCodeAt(L)^x[L%x.length]^g[L%g.length]^L*31+17&255;I+=String.fromCharCode(P)}return m+":"+btoa(I)}function y(n,r){if(!n)return"";try{if(n.indexOf(":")===16){const I=n.substring(0,16),L=new Uint8Array(I.match(/.{2}/g).map($=>parseInt($,16))),P=atob(n.substring(17)),C=new TextEncoder().encode(r+I);let B="";for(let $=0;$<P.length;$++){const E=P.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(E)}return B}const m=atob(n);let x="";for(let I=0;I<m.length;I++){const L=m.charCodeAt(I)^r.charCodeAt(I%r.length);x+=String.fromCharCode(L)}return x}catch{return""}}function c(n,r,g){const m=safeGet(`${n}-${r}-${g}-enc`);return y(m,h)}function p(n,r,g,m){const x=`${n}-${r}-${g}-enc`;m?safeSet(x,u(m,h)):safeRemove(x)}function d(n,r){return c(n,r,"token")}function o(n,r){return c(n,r,"username")}function v(n,r,g){p(n,r,"token",g)}function i(n,r,g){p(n,r,"username",g)}function t(){const n={};for(const r of l())n[r]={readonly:{username:o(r,"readonly"),token:d(r,"readonly")},admin:{username:o(r,"admin"),token:d(r,"admin")}};return n}function e(){l().forEach(n=>{["readonly","admin"].forEach(r=>{["token","username"].forEach(g=>{safeRemove(`${n}-${r}-${g}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function s(n){const r=d(n,"readonly"),g=o(n,"readonly"),m=d(n,"admin"),x=o(n,"admin"),I=y(safeGet(`${n}-token-enc`),h),L=y(safeGet(`${n}-username-enc`),h);return{username:x||g||L,token:m||r||I,readonlyToken:r||I,readonlyUsername:g||L,adminToken:m||I,adminUsername:x||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{f();const n=document.getElementById("token-management-modal"),r=t();l().forEach(g=>{const m=r[g];document.getElementById(`${g}-readonly-username`).value=m.readonly.username,document.getElementById(`${g}-readonly-token`).value=m.readonly.token,document.getElementById(`${g}-admin-username`).value=m.admin.username,document.getElementById(`${g}-admin-token`).value=m.admin.token,document.getElementById(`${g}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const r=n.target.closest(".token-toggle");if(r){const g=r.dataset.target,m=document.getElementById(g);m.type==="password"?(m.type="text",r.textContent="\u{1F648}"):(m.type="password",r.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=l();n.forEach(m=>{i(m,"readonly",document.getElementById(`${m}-readonly-username`).value.trim()),v(m,"readonly",document.getElementById(`${m}-readonly-token`).value.trim()),i(m,"admin",document.getElementById(`${m}-admin-username`).value.trim()),v(m,"admin",document.getElementById(`${m}-admin-token`).value.trim())});const r={};let g=!1;if(n.forEach(m=>{const x={},I=document.getElementById(`${m}-readonly-username`).value.trim(),L=document.getElementById(`${m}-readonly-token`).value.trim(),P=document.getElementById(`${m}-admin-username`).value.trim(),C=document.getElementById(`${m}-admin-token`).value.trim();I&&L&&(x.readonly={username:I,password:L},g=!0),P&&C&&(x.admin={username:P,password:C},g=!0),Object.keys(x).length>0&&(r[m]=x)}),g){n.forEach(m=>{r[m]&&(document.getElementById(`${m}-token-status`).textContent="Verifying...",document.getElementById(`${m}-token-status`).className="token-status")});try{const x=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:r})})).json();x.results?n.forEach(I=>{const L=document.getElementById(`${I}-token-status`);if(!r[I]){L.textContent="";return}const P=x.results[I];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):x.success?n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2713 Saved",document.getElementById(`${I}-token-status`).className="token-status success")}):n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2717 "+(x.error||"Failed"),document.getElementById(`${I}-token-status`).className="token-status error")})}catch(m){console.erro Line truncated
`)}function h(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const r=safeGet("dashcaddy-encryption-key");if(r)return safeSessionSet("dashcaddy-encryption-key",r),safeRemove("dashcaddy-encryption-key"),r;const f=new Uint8Array(32);return crypto.getRandomValues(f),n=Array.from(f,p=>p.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const b=h();function y(n,r){if(!n)return"";const f=crypto.getRandomValues(new Uint8Array(8)),p=Array.from(f,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(r+p);let I="";for(let L=0;L<n.length;L++){const P=n.charCodeAt(L)^w[L%w.length]^f[L%f.length]^L*31+17&255;I+=String.fromCharCode(P)}return p+":"+btoa(I)}function v(n,r){if(!n)return"";try{if(n.indexOf(":")===16){const I=n.substring(0,16),L=new Uint8Array(I.match(/.{2}/g).map($=>parseInt($,16))),P=atob(n.substring(17)),C=new TextEncoder().encode(r+I);let B="";for(let $=0;$<P.length;$++){const E=P.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(E)}return B}const p=atob(n);let w="";for(let I=0;I<p.length;I++){const L=p.charCodeAt(I)^r.charCodeAt(I%r.length);w+=String.fromCharCode(L)}return w}catch{return""}}function u(n,r,f){const p=safeGet(`${n}-${r}-${f}-enc`);return v(p,b)}function l(n,r,f,p){const w=`${n}-${r}-${f}-enc`;p?safeSet(w,y(p,b)):safeRemove(w)}function a(n,r){return u(n,r,"token")}function o(n,r){return u(n,r,"username")}function g(n,r,f){l(n,r,"token",f)}function d(n,r,f){l(n,r,"username",f)}function t(){const n={};for(const r of c())n[r]={readonly:{username:o(r,"readonly"),token:a(r,"readonly")},admin:{username:o(r,"admin"),token:a(r,"admin")}};return n}function e(){c().forEach(n=>{["readonly","admin"].forEach(r=>{["token","username"].forEach(f=>{safeRemove(`${n}-${r}-${f}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function i(n){const r=a(n,"readonly"),f=o(n,"readonly"),p=a(n,"admin"),w=o(n,"admin"),I=v(safeGet(`${n}-token-enc`),b),L=v(safeGet(`${n}-username-enc`),b);return{username:w||f||L,token:p||r||I,readonlyToken:r||I,readonlyUsername:f||L,adminToken:p||I,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{m();const n=document.getElementById("token-management-modal"),r=t();c().forEach(f=>{const p=r[f];document.getElementById(`${f}-readonly-username`).value=p.readonly.username,document.getElementById(`${f}-readonly-token`).value=p.readonly.token,document.getElementById(`${f}-admin-username`).value=p.admin.username,document.getElementById(`${f}-admin-token`).value=p.admin.token,document.getElementById(`${f}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const r=n.target.closest(".token-toggle");if(r){const f=r.dataset.target,p=document.getElementById(f);p.type==="password"?(p.type="text",r.textContent="\u{1F648}"):(p.type="password",r.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=c();n.forEach(p=>{d(p,"readonly",document.getElementById(`${p}-readonly-username`).value.trim()),g(p,"readonly",document.getElementById(`${p}-readonly-token`).value.trim()),d(p,"admin",document.getElementById(`${p}-admin-username`).value.trim()),g(p,"admin",document.getElementById(`${p}-admin-token`).value.trim())});const r={};let f=!1;if(n.forEach(p=>{const w={},I=document.getElementById(`${p}-readonly-username`).value.trim(),L=document.getElementById(`${p}-readonly-token`).value.trim(),P=document.getElementById(`${p}-admin-username`).value.trim(),C=document.getElementById(`${p}-admin-token`).value.trim();I&&L&&(w.readonly={username:I,password:L},f=!0),P&&C&&(w.admin={username:P,password:C},f=!0),Object.keys(w).length>0&&(r[p]=w)}),f){n.forEach(p=>{r[p]&&(document.getElementById(`${p}-token-status`).textContent="Verifying...",document.getElementById(`${p}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:r})})).json();w.results?n.forEach(I=>{const L=document.getElementById(`${I}-token-status`);if(!r[I]){L.textContent="";return}const P=w.results[I];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2713 Saved",document.getElementById(`${I}-token-status`).className="token-status success")}):n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${I}-token-status`).className="token-status error")})}catch(p){console.erro Line truncated
Current: ${i.currentVersion}
New: ${i.updateVersion}
Current: ${d.currentVersion}
New: ${d.updateVersion}
`+(i.updateTitle?`${i.updateTitle}
`+(d.updateTitle?`${d.updateTitle}
`:"")+`The DNS server will restart during the update.
Proceed?`)){d.textContent=o,d.disabled=!1,d.title="Update DNS server";return}d.textContent="\u{1F504}",d.title="Updating...";const s=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(p)}`,{method:"POST"})).json();if(!s.success)throw new Error(s.error||"Update failed");if(s.manualUpdateRequired){d.textContent="\u2B06\uFE0F",d.title=`Update available: ${s.newVersion}`;const n=s.downloadLink?`
Download: ${s.downloadLink}`:"",r=s.instructionsLink?`
Instructions: ${s.instructionsLink}`:"";showNotification(`${c.toUpperCase()} update requires manual installation. Current: ${s.previousVersion} \u2192 ${s.newVersion}. Please update manually on the host machine.`,"warning",8e3),d.disabled=!1;return}d.textContent="\u2705",d.title="Updated successfully!",showNotification(`${c.toUpperCase()} updated successfully! ${s.previousVersion} \u2192 ${s.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server",window.refreshAll()},1e4)}catch(v){console.error("DNS update error:",v),d.textContent="\u274C",d.title="Update failed",showNotification(`Failed to update ${c.toUpperCase()}: ${v.message}`,"error"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",c=>{const p=c.target.closest('[id$="-update"]');if(!p)return;const d=p.id.replace("-update","");SITE.dnsServers[d]&&a(d,SITE.dnsServers[d]?.ip)}),injectModal("dns-settings-modal",`
Proceed?`)){a.textContent=o,a.disabled=!1,a.title="Update DNS server";return}a.textContent="\u{1F504}",a.title="Updating...";const i=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(l)}`,{method:"POST"})).json();if(!i.success)throw new Error(i.error||"Update failed");if(i.manualUpdateRequired){a.textContent="\u2B06\uFE0F",a.title=`Update available: ${i.newVersion}`;const n=i.downloadLink?`
Download: ${i.downloadLink}`:"",r=i.instructionsLink?`
Instructions: ${i.instructionsLink}`:"";showNotification(`${u.toUpperCase()} update requires manual installation. Current: ${i.previousVersion} \u2192 ${i.newVersion}. Please update manually on the host machine.`,"warning",8e3),a.disabled=!1;return}a.textContent="\u2705",a.title="Updated successfully!",showNotification(`${u.toUpperCase()} updated successfully! ${i.previousVersion} \u2192 ${i.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{a.textContent=o,a.disabled=!1,a.title="Update DNS server",window.refreshAll()},1e4)}catch(g){console.error("DNS update error:",g),a.textContent="\u274C",a.title="Update failed",showNotification(`Failed to update ${u.toUpperCase()}: ${g.message}`,"error"),setTimeout(()=>{a.textContent=o,a.disabled=!1,a.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-update"]');if(!l)return;const a=l.id.replace("-update","");SITE.dnsServers[a]&&s(a,SITE.dnsServers[a]?.ip)}),injectModal("dns-settings-modal",`
<div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3>
@@ -366,7 +366,7 @@ Instructions: ${s.instructionsLink}`:"";showNotification(`${c.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);let f=null;function b(c){f=c;const p=SITE.dnsServers[c]||{},d=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(p.name||c).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=p.ip||"",document.getElementById("dns-edit-port").value=p.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=p.name||"",d.classList.add("show")}async function h(){if(!f)return;const c=document.getElementById("dns-edit-ip").value.trim(),p=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,d=document.getElementById("dns-edit-name").value.trim();if(!c){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[f]={ip:c,port:String(p)},d&&(o.dnsServers[f].name=d);try{const i=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();i.success?(SITE.dnsServers[f]=o.dnsServers[f],showNotification(`${f.toUpperCase()} settings saved`,"success"),y(),window.refreshAll()):showNotification(i.error||"Failed to save settings","error")}catch(v){showNotification("Failed to save: "+v.message,"error")}}async function u(){if(f&&confirm(`Remove ${f.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const p=await(await secureFetch("/api/v1/config")).json();p.dnsServers&&delete p.dnsServers[f];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:p.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[f];const v=document.querySelector(`.top [data-app="${f}"]`);v&&v.remove(),showNotification(`${f.toUpperCase()} removed from dashboard`,"success"),y()}else showNotification(o.error||"Failed to remove","error")}catch(c){showNotification("Failed to remove: "+c.message,"error")}}function y(){closeModal("dns-settings-modal"),f=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",y),document.getElementById("dns-settings-save")?.addEventListener("click",h),document.getElementById("dns-settings-delete")?.addEventListener("click",u),document.getElementById("dns-settings-modal")?.addEventListener("click",c=>{c.target.id==="dns-settings-modal"&&y()}),document.querySelector(".top")?.addEventListener("click",c=>{const p=c.target.closest('[id$="-settings"]');if(!p)return;const d=p.id.replace("-settings","");SITE.dnsServers[d]&&(c.stopPropagation(),b(d))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
</div>`);let m=null;function h(u){m=u;const l=SITE.dnsServers[u]||{},a=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(l.name||u).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=l.ip||"",document.getElementById("dns-edit-port").value=l.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=l.name||"",a.classList.add("show")}async function b(){if(!m)return;const u=document.getElementById("dns-edit-ip").value.trim(),l=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,a=document.getElementById("dns-edit-name").value.trim();if(!u){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[m]={ip:u,port:String(l)},a&&(o.dnsServers[m].name=a);try{const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();d.success?(SITE.dnsServers[m]=o.dnsServers[m],showNotification(`${m.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(d.error||"Failed to save settings","error")}catch(g){showNotification("Failed to save: "+g.message,"error")}}async function y(){if(m&&confirm(`Remove ${m.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const l=await(await secureFetch("/api/v1/config")).json();l.dnsServers&&delete l.dnsServers[m];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:l.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[m];const g=document.querySelector(`.top [data-app="${m}"]`);g&&g.remove(),showNotification(`${m.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(o.error||"Failed to remove","error")}catch(u){showNotification("Failed to remove: "+u.message,"error")}}function v(){closeModal("dns-settings-modal"),m=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",b),document.getElementById("dns-settings-delete")?.addEventListener("click",y),document.getElementById("dns-settings-modal")?.addEventListener("click",u=>{u.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-settings"]');if(!l)return;const a=l.id.replace("-settings","");SITE.dnsServers[a]&&(u.stopPropagation(),h(a))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
<div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header">
@@ -390,66 +390,66 @@ Instructions: ${s.instructionsLink}`:"";showNotification(`${c.toUpperCase()} upd
</div>
</div>
</div>
</div>`);let l=null,a=null,f=!1,b=null,h=null,u=!1,y=null,c=null,p=!1,d=null,o=!1;async function v(E,w=25){try{const S=getDnsServerAddr(E),T=await fetch(`/api/v1/dns/logs?server=${S}&limit=${w}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,server:k.server}:{error:k.error||"Failed to fetch logs"}}else return T.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${T.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function i(E){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[E]||"var(--fg)"}function t(E){const w=document.createElement("div");if(w.className="log-entry",w.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",E.parsed===!1)return w.style.gridTemplateColumns="1fr",w.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,w;const S=i(E.rcode),T=E.rcode==="Refused"||E.rcode==="REFUSED";return w.innerHTML=`
</div>`);let c=null,s=null,m=!1,h=null,b=null,y=!1,v=null,u=null,l=!1,a=null,o=!1;async function g(E,x=25){try{const S=getDnsServerAddr(E),T=await fetch(`/api/v1/dns/logs?server=${S}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,server:k.server}:{error:k.error||"Failed to fetch logs"}}else return T.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${T.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function d(E){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[E]||"var(--fg)"}function t(E){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",E.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,x;const S=d(E.rcode),T=E.rcode==="Refused"||E.rcode==="REFUSED";return x.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(E.client)}">${escapeHtml(E.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${T?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(E.domain)}">${escapeHtml(E.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.type)}</span>
<span style="color: ${S}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(E.rcode)}</span>
`,w}async function e(){if(p){await B();return}if(u){await I();return}if(f||!l)return;const E=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const S=await v(l,E);if(S.error){w.innerHTML=`
`,x}async function e(){if(l){await B();return}if(y){await I();return}if(m||!c)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await g(c,E);if(S.error){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div>
</div>`;return}w.innerHTML=`
</div>`;return}x.innerHTML=`
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span>Time</span>
<span>Client</span>
<span>Domain</span>
<span>Type</span>
<span>Status</span>
</div>`,S.logs&&S.logs.length>0?S.logs.forEach(T=>{const k=t(T);w.appendChild(k)}):w.innerHTML+=`
</div>`,S.logs&&S.logs.length>0?S.logs.forEach(T=>{const k=t(T);x.appendChild(k)}):x.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet
</div>`}catch(S){w.innerHTML=`
</div>`}catch(S){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function s(E){l=E,f=!1,u=!1;const w=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),T=document.getElementById("logs-pause"),k=document.getElementById("logs-stream");S.textContent=`${E.toUpperCase()} DNS Logs`,T.textContent="\u23F8\uFE0F Pause",T.classList.remove("paused"),k&&(k.style.display="none"),w.classList.add("show"),e(),a=setInterval(e,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),g(),l=null,u=!1,b=null,h=null,p=!1,y=null,c=null,f=!1}function r(E){d&&g();const w=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),T=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{d=new EventSource(`/api/v1/logs/stream/${E}`),o=!0,w.classList.add("active"),w.textContent="\u{1F534} Live",w.title="Streaming - click to stop",S.style.display="none";const k=document.getElementById("logs-title");k.textContent.includes("\u{1F534}")||(k.innerHTML=k.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),d.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),g();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const U=(D.stream||"stdout")==="stderr",N=U?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${U?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${U?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
</div>`}}function i(E){c=E,m=!1,y=!1;const x=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),T=document.getElementById("logs-pause"),k=document.getElementById("logs-stream");S.textContent=`${E.toUpperCase()} DNS Logs`,T.textContent="\u23F8\uFE0F Pause",T.classList.remove("paused"),k&&(k.style.display="none"),x.classList.add("show"),e(),s=setInterval(e,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),s&&(clearInterval(s),s=null),f(),c=null,y=!1,h=null,b=null,l=!1,v=null,u=null,m=!1}function r(E){a&&f();const x=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),T=document.getElementById("logs-content");s&&(clearInterval(s),s=null);try{a=new EventSource(`/api/v1/logs/stream/${E}`),o=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",S.style.display="none";const k=document.getElementById("logs-title");k.textContent.includes("\u{1F534}")||(k.innerHTML=k.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),a.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),f();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const _=(D.stream||"stdout")==="stderr",N=_?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${_?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${_?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
<div style="flex-shrink: 0;">${M}</div>
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
`,T.appendChild(O),T.scrollTop=T.scrollHeight;T.children.length>500;)T.removeChild(T.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},d.onerror=A=>{console.error("EventSource error:",A),g()}}catch(k){console.error("Failed to start streaming:",k),g()}}function g(){d&&(d.close(),d=null),o=!1;const E=document.getElementById("logs-stream"),w=document.getElementById("logs-pause"),S=document.getElementById("logs-title");E&&(E.classList.remove("active"),E.textContent="\u{1F4E1} Live",E.title="Enable real-time streaming"),w&&(w.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),u&&b&&!a&&(a=setInterval(I,DC.POLL.LOGS))}async function m(E,w=100){try{const S=`/api/v1/logs/container/${E}?tail=${w}&timestamps=true`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,containerName:k.containerName,containerId:k.containerId}:{error:k.error||"Failed to fetch container logs"}}else return{error:`HTTP ${T.status}: ${T.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function x(E){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.stream==="stderr"?"var(--bad-fg)":"var(--fg)",T=E.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return w.innerHTML=`
`,T.appendChild(O),T.scrollTop=T.scrollHeight;T.children.length>500;)T.removeChild(T.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},a.onerror=A=>{console.error("EventSource error:",A),f()}}catch(k){console.error("Failed to start streaming:",k),f()}}function f(){a&&(a.close(),a=null),o=!1;const E=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),S=document.getElementById("logs-title");E&&(E.classList.remove("active"),E.textContent="\u{1F4E1} Live",E.title="Enable real-time streaming"),x&&(x.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),y&&h&&!s&&(s=setInterval(I,DC.POLL.LOGS))}async function p(E,x=100){try{const S=`/api/v1/logs/container/${E}?tail=${x}&timestamps=true`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,containerName:k.containerName,containerId:k.containerId}:{error:k.error||"Failed to fetch container logs"}}else return{error:`HTTP ${T.status}: ${T.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function w(E){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.stream==="stderr"?"var(--bad-fg)":"var(--fg)",T=E.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
<div style="flex-shrink: 0;">${T}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(E.text)}</div>
`,w}async function I(){if(f||!b||!u)return;const E=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const S=await m(b,E);if(S.error){w.innerHTML=`
`,x}async function I(){if(m||!h||!y)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await p(h,E);if(S.error){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div>
</div>`;return}w.innerHTML=`
</div>`;return}x.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex-shrink: 0; width: 80px;">Stream</span>
<span style="flex: 1;">Log Output</span>
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=x(T);w.appendChild(k)}),w.scrollTop=w.scrollHeight):w.innerHTML+=`
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=w(T);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container
</div>`}catch(S){w.innerHTML=`
</div>`}catch(S){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function L(E,w){b=E,h=w,u=!0,p=!1,f=!1,g();const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${w} - Container Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display=""),S.classList.add("show"),I(),a=setInterval(I,DC.POLL.LOGS)}async function P(E,w=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${w}`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,logPath:k.logPath,totalLines:k.totalLines}:{error:k.error||"Failed to fetch file logs"}}else return{error:(await T.json().catch(()=>({}))).error||`HTTP ${T.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function C(E){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.text;let T="INFO",k="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(T="ERROR",k="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(T="WARN",k="#f39c12"):S.match(/DEBUG/i)&&(T="DEBUG",k="var(--muted)");const D=`<span style="background: ${k==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${k}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${T}</span>`;return w.innerHTML=`
</div>`}}function L(E,x){h=E,b=x,y=!0,l=!1,m=!1,f();const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${x} - Container Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display=""),S.classList.add("show"),I(),s=setInterval(I,DC.POLL.LOGS)}async function P(E,x=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${x}`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,logPath:k.logPath,totalLines:k.totalLines}:{error:k.error||"Failed to fetch file logs"}}else return{error:(await T.json().catch(()=>({}))).error||`HTTP ${T.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function C(E){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.text;let T="INFO",k="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(T="ERROR",k="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(T="WARN",k="#f39c12"):S.match(/DEBUG/i)&&(T="DEBUG",k="var(--muted)");const D=`<span style="background: ${k==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${k}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${T}</span>`;return x.innerHTML=`
<div style="flex-shrink: 0;">${D}</div>
<div style="flex: 1; color: ${k}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div>
`,w}async function B(){if(f||!y||!p)return;const E=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const S=await P(y,E);if(S.error){w.innerHTML=`
`,x}async function B(){if(m||!v||!l)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await P(v,E);if(S.error){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div>
</div>`;return}w.innerHTML=`
</div>`;return}x.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex: 1;">Log Output (${S.count} of ${S.totalLines} lines)</span>
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=C(T);w.appendChild(k)}),w.scrollTop=w.scrollHeight):w.innerHTML+=`
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=C(T);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file
</div>`}catch(S){w.innerHTML=`
</div>`}catch(S){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function $(E,w){y=E,c=w,p=!0,u=!1,f=!1;const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${w} - Application Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display="none"),S.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const w=E.target.closest('[id$="-logs"]');if(!w)return;const S=w.id.replace("-logs","");SITE.dnsServers[S]&&s(S)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{f=!f;const E=document.getElementById("logs-pause");f?(E.textContent="\u25B6\uFE0F Resume",E.classList.add("paused")):(E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),e())}),document.getElementById("log-lines")?.addEventListener("change",()=>{f||e()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!u||!b||(o?g():r(b))}),document.getElementById("logs-modal")?.addEventListener("click",E=>{E.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",E=>{E.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=s})(),(function(){injectModal("service-edit-modal",`
</div>`}}function $(E,x){v=E,u=x,l=!0,y=!1,m=!1;const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${x} - Application Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display="none"),S.classList.add("show"),B(),s=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const x=E.target.closest('[id$="-logs"]');if(!x)return;const S=x.id.replace("-logs","");SITE.dnsServers[S]&&i(S)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{m=!m;const E=document.getElementById("logs-pause");m?(E.textContent="\u25B6\uFE0F Resume",E.classList.add("paused")):(E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),e())}),document.getElementById("log-lines")?.addEventListener("change",()=>{m||e()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!y||!h||(o?f():r(h))}),document.getElementById("logs-modal")?.addEventListener("click",E=>{E.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",E=>{E.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=i})(),(function(){injectModal("service-edit-modal",`
<div id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3>
@@ -805,45 +805,45 @@ Instructions: ${s.instructionsLink}`:"";showNotification(`${c.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button>
</div>
</div>
</div>`)})(),(function(){async function l(u){try{const y=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(u)}`);if(!y.ok)throw new Error(`Failed to load CAs: ${y.status}`);const c=await y.json();if(c.success){const p=document.getElementById("existing-ca-select");return p.innerHTML="",c.cas.length===0?p.innerHTML='<option value="">No CAs found in Caddyfile</option>':(p.innerHTML='<option value="">Select existing CA...</option>',c.cas.forEach(d=>{const o=document.createElement("option");typeof d=="object"?(o.value=d.id,o.textContent=d.displayName||d.name):(o.value=d,o.textContent=d),p.appendChild(o)})),c.data.cas}else throw new Error(c.message)}catch(y){console.error("Error loading CAs:",y);const c=document.getElementById("existing-ca-select");return c.innerHTML='<option value="">Error loading CAs</option>',[]}}function a(u){const{subdomain:y,port:c,ip:p,sslType:d,caName:o,existingCa:v,enableAuth:i,enableCors:t,customHeaders:e,upstreamPath:s,healthCheck:n,timeout:r,tailscaleOnly:g}=u;let m=`${buildDomain(y)} {
`;switch(g&&(m+=` @blocked not remote_ip 100.64.0.0/10
`,m+=` respond @blocked "Access denied. Tailscale connection required." 403
`),d){case"letsencrypt":break;case"caddy-managed":m+=` tls internal
`;break;case"existing-ca":v&&(m+=` tls {
ca ${v}
</div>`)})(),(function(){async function c(y){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(y)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const u=await v.json();if(u.success){const l=document.getElementById("existing-ca-select");return l.innerHTML="",u.cas.length===0?l.innerHTML='<option value="">No CAs found in Caddyfile</option>':(l.innerHTML='<option value="">Select existing CA...</option>',u.cas.forEach(a=>{const o=document.createElement("option");typeof a=="object"?(o.value=a.id,o.textContent=a.displayName||a.name):(o.value=a,o.textContent=a),l.appendChild(o)})),u.data.cas}else throw new Error(u.message)}catch(v){console.error("Error loading CAs:",v);const u=document.getElementById("existing-ca-select");return u.innerHTML='<option value="">Error loading CAs</option>',[]}}function s(y){const{subdomain:v,port:u,ip:l,sslType:a,caName:o,existingCa:g,enableAuth:d,enableCors:t,customHeaders:e,upstreamPath:i,healthCheck:n,timeout:r,tailscaleOnly:f}=y;let p=`${buildDomain(v)} {
`;switch(f&&(p+=` @blocked not remote_ip 100.64.0.0/10
`,p+=` respond @blocked "Access denied. Tailscale connection required." 403
`),a){case"letsencrypt":break;case"caddy-managed":p+=` tls internal
`;break;case"existing-ca":g&&(p+=` tls {
ca ${g}
}
`);break;case"custom-ca":o&&(m+=` tls {
`);break;case"custom-ca":o&&(p+=` tls {
ca ${o}
}
`);break}if(i&&(m+=` basicauth {
`);break}if(d&&(p+=` basicauth {
admin $2a$14$hashed_password_here
}
`),t&&(m+=` header {
`,m+=` Access-Control-Allow-Origin "*"
`,m+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,m+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,m+=` }
`),e)try{const x=JSON.parse(e);m+=` header {
`,Object.entries(x).forEach(([I,L])=>{m+=` ${I} "${L}"
`}),m+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(m+=` health_uri ${n}
`),m+=` reverse_proxy ${p}:${c} {
`,s&&s!=="/"&&(m+=` rewrite ${s}
`),r&&r!==30&&(m+=` transport http {
`,m+=` dial_timeout ${r}s
`,m+=` response_header_timeout ${r}s
`,m+=` }
`),m+=` }
`,m+=`}
`,m}async function f(u,y,c=DC.DEFAULTS.TTL){const p=window.getToken(getPrimaryDnsId(),"admin");if(!p)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const d=buildDomain(u),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:d,ip:y,ttl:c,token:p,server:SITE.dnsIp})});if(!o.ok){const i=await o.text();throw new Error(`DNS API Error: ${o.status} - ${i}`)}const v=await o.json();if(!v.success)throw new Error(`DNS Error: ${v.error||"Unknown error"}`);return v}async function b(u){const y={id:u.subdomain,name:u.name,logo:u.logo||`/assets/${u.subdomain}.png`};u.category&&(y.category=u.category),u.containerId&&(y.containerId=u.containerId);try{const c=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(y)});if(!c.ok){const p=await c.json();throw new Error(p.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),y}catch(c){throw console.error("Failed to add service to config:",c),c}}async function h(u){const y=document.getElementById("service-subdomain-input").value.trim(),c=document.getElementById("service-ip-input").value.trim()||"localhost",p=document.getElementById("service-port-input").value.trim()||"80",d=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(y),upstream:`${c}:${p}`,config:u})}),o=await d.json();if(!d.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${d.status}`);return o}window.loadExistingCAs=l,window.generateCaddyConfig=a,window.createDnsRecord=f,window.addServiceToConfig=b,window.addToCaddyfile=h})(),(function(){let l=null;function a(c){l=c;const p=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${c.name}`,document.getElementById("edit-service-name").value=c.name,document.getElementById("edit-service-url-display").textContent=c.url||buildServiceUrl(c.id),document.getElementById("edit-service-logo-preview").src=c.logo||`/assets/${c.id}.png`,document.getElementById("edit-subdomain").value=c.id,document.getElementById("edit-port").value=c.port||"",document.getElementById("edit-ip").value=c.ip||"localhost",document.getElementById("edit-tailscale-only").checked=c.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=c.logo||"";const d=document.getElementById("edit-service-category");d&&(d.dataset.current=c.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),p.classList.add("show")}function f(){closeModal("service-edit-modal"),l=null}async function b(){if(!l)return;const c=document.getElementById("edit-subdomain").value.trim().toLowerCase(),p=document.getElementById("edit-service-name").value.trim(),d=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",v=document.getElementById("edit-tailscale-only").checked,i=document.getElementById("edit-logo-url").value.trim(),t=document.getElementById("edit-service-category")?.value||"";if(!c){showNotification("Subdomain is required","warning");return}const e=l.id,s=[];if(c!==e&&s.push("subdomain"),p&&p!==l.name&&s.push("name"),d&&d!==String(l.port)&&s.push("port"),o!==l.ip&&s.push("ip"),v!==(l.tailscaleOnly||!1)&&s.push("tailscale"),i&&i!==l.logo&&s.push("logo"),t!==(l.category||"")&&s.push("category"),s.length===0){f();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const g=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:c,name:p||l.name,port:d||l.port,ip:o,tailscaleOnly:v,logo:i||void 0,category:t})})).json();if(!g.success)throw new Error(g.error||"Failed to update service");const m=window.APPS.findIndex(x=>x.id===e);m!==-1&&(window.APPS[m]={...window.APPS[m],id:c,name:p||window.APPS[m].name,port:d||window.APPS[m].port,ip:o,tailscaleOnly:v,logo:i||window.APPS[m].logo,category:t||void 0}),f(),window.buildGrid(),window.refreshAll()}catch(r){console.error("Error saving service changes:",r),showNotification(`Error saving changes: ${r.message}`,"error")}finally{n.textContent="Save Changes",n.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async c=>{const p=c.target.files[0];if(!p)return;if(!p.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const d=new FileReader;d.onload=async o=>{const v=o.target.result;if(document.getElementById("edit-service-logo-preview").src=v,document.getElementById("edit-logo-url").value=v,l)try{const t=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:v})})).json();t.success&&t.path&&(document.getElementById("edit-logo-url").val Line truncated
`),t&&(p+=` header {
`,p+=` Access-Control-Allow-Origin "*"
`,p+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,p+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,p+=` }
`),e)try{const w=JSON.parse(e);p+=` header {
`,Object.entries(w).forEach(([I,L])=>{p+=` ${I} "${L}"
`}),p+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(p+=` health_uri ${n}
`),p+=` reverse_proxy ${l}:${u} {
`,i&&i!=="/"&&(p+=` rewrite ${i}
`),r&&r!==30&&(p+=` transport http {
`,p+=` dial_timeout ${r}s
`,p+=` response_header_timeout ${r}s
`,p+=` }
`),p+=` }
`,p+=`}
`,p}async function m(y,v,u=DC.DEFAULTS.TTL){const l=window.getToken(getPrimaryDnsId(),"admin");if(!l)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const a=buildDomain(y),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:a,ip:v,ttl:u,token:l,server:SITE.dnsIp})});if(!o.ok){const d=await o.text();throw new Error(`DNS API Error: ${o.status} - ${d}`)}const g=await o.json();if(!g.success)throw new Error(`DNS Error: ${g.error||"Unknown error"}`);return g}async function h(y){const v={id:y.subdomain,name:y.name,logo:y.logo||`/assets/${y.subdomain}.png`};y.category&&(v.category=y.category),y.containerId&&(v.containerId=y.containerId);try{const u=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!u.ok){const l=await u.json();throw new Error(l.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(u){throw console.error("Failed to add service to config:",u),u}}async function b(y){const v=document.getElementById("service-subdomain-input").value.trim(),u=document.getElementById("service-ip-input").value.trim()||"localhost",l=document.getElementById("service-port-input").value.trim()||"80",a=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${u}:${l}`,config:y})}),o=await a.json();if(!a.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${a.status}`);return o}window.loadExistingCAs=c,window.generateCaddyConfig=s,window.createDnsRecord=m,window.addServiceToConfig=h,window.addToCaddyfile=b})(),(function(){let c=null;function s(u){c=u;const l=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${u.name}`,document.getElementById("edit-service-name").value=u.name,document.getElementById("edit-service-url-display").textContent=u.url||buildServiceUrl(u.id),document.getElementById("edit-service-logo-preview").src=u.logo||`/assets/${u.id}.png`,document.getElementById("edit-subdomain").value=u.id,document.getElementById("edit-port").value=u.port||"",document.getElementById("edit-ip").value=u.ip||"localhost",document.getElementById("edit-tailscale-only").checked=u.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=u.logo||"";const a=document.getElementById("edit-service-category");a&&(a.dataset.current=u.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),l.classList.add("show")}function m(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const u=document.getElementById("edit-subdomain").value.trim().toLowerCase(),l=document.getElementById("edit-service-name").value.trim(),a=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",g=document.getElementById("edit-tailscale-only").checked,d=document.getElementById("edit-logo-url").value.trim(),t=document.getElementById("edit-service-category")?.value||"";if(!u){showNotification("Subdomain is required","warning");return}const e=c.id,i=[];if(u!==e&&i.push("subdomain"),l&&l!==c.name&&i.push("name"),a&&a!==String(c.port)&&i.push("port"),o!==c.ip&&i.push("ip"),g!==(c.tailscaleOnly||!1)&&i.push("tailscale"),d&&d!==c.logo&&i.push("logo"),t!==(c.category||"")&&i.push("category"),i.length===0){m();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const f=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:u,name:l||c.name,port:a||c.port,ip:o,tailscaleOnly:g,logo:d||void 0,category:t})})).json();if(!f.success)throw new Error(f.error||"Failed to update service");const p=window.APPS.findIndex(w=>w.id===e);p!==-1&&(window.APPS[p]={...window.APPS[p],id:u,name:l||window.APPS[p].name,port:a||window.APPS[p].port,ip:o,tailscaleOnly:g,logo:d||window.APPS[p].logo,category:t||void 0}),m(),window.buildGrid(),window.refreshAll()}catch(r){console.error("Error saving service changes:",r),showNotification(`Error saving changes: ${r.message}`,"error")}finally{n.textContent="Save Changes",n.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async u=>{const l=u.target.files[0];if(!l)return;if(!l.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=async o=>{const g=o.target.result;if(document.getElementById("edit-service-logo-preview").src=g,document.getElementById("edit-logo-url").value=g,c)try{const t=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${c.id}.png`,data:g})})).json();t.success&&t.path&&(document.getElementById("edit-logo-url").val Line truncated
This will:
1. Pull the latest image
2. Stop the container
3. Recreate with same settings
The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const t=await(await secureFetch(`/api/v1/containers/${c}/update`,{method:"POST"})).json();if(t.success){const e=window.APPS.find(s=>s.id===d);e&&t.newContainerId&&(e.containerId=t.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=v,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${p} updated successfully!`,"success")}else throw new Error(t.error||"Update failed")}catch(i){console.error("Update error:",i),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=v,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${p}: ${i.message}`,"error")}}async function y(c,p){const d=window.APPS.find(m=>m.id===c),o=d?buildDomain(d.id):null,v=d?.containerId,i=await h(p||c,v,d?.containerId);if(i===null)return;let t={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(i&&v)try{const m=new URLSearchParams({containerId:d.containerId,subdomain:d.id,ip:d.ip||"localhost",deleteContainer:"true"}),I=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(d.id)}?${m.toString()}`,{method:"DELETE"})).json();I.success?t={...t,...I.results,dashboard:!1}:console.error("App removal failed:",I.error)}catch(m){console.error("App removal error:",m)}else if(i&&o){try{const m=d?.ip||"localhost",I=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(m)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();t.dns=I.success?"deleted":I.error||"failed"}catch(m){t.dns=m.message}try{const x=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();t.caddy=x.success||x.error&&x.error.includes("not found")?"removed":x.error||"failed"}catch(m){t.caddy=m.message}}const e=window.APPS.findIndex(m=>m.id===c);e>-1&&(window.APPS.splice(e,1),t.dashboard=!0);try{const m=safeGetJSON("custom-apps",[]),x=m.findIndex(I=>I.id===c);x>-1&&(m.splice(x,1),safeSet("custom-apps",JSON.stringify(m)))}catch{}try{const x=await(await secureFetch(`/api/v1/services/${encodeURIComponent(c)}`,{method:"DELETE"})).json();t.service=x.success?"removed":x.error||"failed"}catch(m){t.service=m.message}window.buildGrid(),window.refreshAll();let s=!1,n=[];t.dashboard||(s=!0,n.push("\u2717 Failed to remove from dashboard"));const r=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],g=m=>!m||r.some(x=>m.toLowerCase().includes(x.toLowerCase()));t.container&&!g(t.container)&&(s=!0,n.push(`\u26A0 Container: ${t.container}`)),t.dns&&!g(t.dns)&&(s=!0,n.push(`\u26A0 DNS Record: ${t.dns}`)),t.caddy&&!g(t.caddy)&&(s=!0,n.push(`\u26A0 Caddy Config: ${t.caddy}`)),t.service&&!g(t.service)&&(s=!0,n.push(`\u26A0 Service File: ${t.service}`)),s&&showNotification(`Error deleting "${p||c}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=h,window.updateContainer=u,window.deleteService=y})(),(function(){function l(t){return t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function a(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function f(){const t=document.getElementById("service-subdomain-input").value||"subdomain",e=document.getElementById("service-ip-input").value||b.lan||"localhost",s=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,r=document.getElementById("ca-name-input").value||"sami-ca",g=document.getElementById("existing-ca-select").value,m=document.getElementById("enable-auth").checked,x=document.getElementById("enable-cors").checked,I=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(t)} \u2192 ${e}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(t));const E={subdomain:t,port:s,ip:e,sslType:n,caName:r,existingCa:g,enableAuth:m,enableCors:x,customHeaders:I,upstreamPath:L,healthCheck:P,timeout:C},w=window.generateCaddyConfig(E),S=document.getElementById("caddy-config-preview");S&&(S.value=w)}const b={localhost:"127.0.0.1",lan:"",tailscale:""};async function h(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const r=await n.json();r.lan&&(b.lan=r.lan),r.tailscale&&(b.tailscale=r.tailscale)}}catch{}const t=document.getElementById("quick-ip-lan"),e=document.getElementById("quick-ip-tailscale");t&&(b.lan?(t.dataset.ip=b.lan,t.textContent=`LAN (${b.lan})`,t.title=`LAN IP: ${b.lan}`):t Line truncated
The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const t=await(await secureFetch(`/api/v1/containers/${u}/update`,{method:"POST"})).json();if(t.success){const e=window.APPS.find(i=>i.id===a);e&&t.newContainerId&&(e.containerId=t.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=g,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${l} updated successfully!`,"success")}else throw new Error(t.error||"Update failed")}catch(d){console.error("Update error:",d),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=g,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${l}: ${d.message}`,"error")}}async function v(u,l){const a=window.APPS.find(p=>p.id===u),o=a?buildDomain(a.id):null,g=a?.containerId,d=await b(l||u,g,a?.containerId);if(d===null)return;let t={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(d&&g)try{const p=new URLSearchParams({containerId:a.containerId,subdomain:a.id,ip:a.ip||"localhost",deleteContainer:"true"}),I=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(a.id)}?${p.toString()}`,{method:"DELETE"})).json();I.success?t={...t,...I.results,dashboard:!1}:console.error("App removal failed:",I.error)}catch(p){console.error("App removal error:",p)}else if(d&&o){try{const p=a?.ip||"localhost",I=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(p)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();t.dns=I.success?"deleted":I.error||"failed"}catch(p){t.dns=p.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();t.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(p){t.caddy=p.message}}const e=window.APPS.findIndex(p=>p.id===u);e>-1&&(window.APPS.splice(e,1),t.dashboard=!0);try{const p=safeGetJSON("custom-apps",[]),w=p.findIndex(I=>I.id===u);w>-1&&(p.splice(w,1),safeSet("custom-apps",JSON.stringify(p)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(u)}`,{method:"DELETE"})).json();t.service=w.success?"removed":w.error||"failed"}catch(p){t.service=p.message}window.buildGrid(),window.refreshAll();let i=!1,n=[];t.dashboard||(i=!0,n.push("\u2717 Failed to remove from dashboard"));const r=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],f=p=>!p||r.some(w=>p.toLowerCase().includes(w.toLowerCase()));t.container&&!f(t.container)&&(i=!0,n.push(`\u26A0 Container: ${t.container}`)),t.dns&&!f(t.dns)&&(i=!0,n.push(`\u26A0 DNS Record: ${t.dns}`)),t.caddy&&!f(t.caddy)&&(i=!0,n.push(`\u26A0 Caddy Config: ${t.caddy}`)),t.service&&!f(t.service)&&(i=!0,n.push(`\u26A0 Service File: ${t.service}`)),i&&showNotification(`Error deleting "${l||u}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=s,window.showDeleteModal=b,window.updateContainer=y,window.deleteService=v})(),(function(){function c(t){return t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function s(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function m(){const t=document.getElementById("service-subdomain-input").value||"subdomain",e=document.getElementById("service-ip-input").value||h.lan||"localhost",i=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,r=document.getElementById("ca-name-input").value||"sami-ca",f=document.getElementById("existing-ca-select").value,p=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,I=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(t)} \u2192 ${e}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(t));const E={subdomain:t,port:i,ip:e,sslType:n,caName:r,existingCa:f,enableAuth:p,enableCors:w,customHeaders:I,upstreamPath:L,healthCheck:P,timeout:C},x=window.generateCaddyConfig(E),S=document.getElementById("caddy-config-preview");S&&(S.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function b(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const r=await n.json();r.lan&&(h.lan=r.lan),r.tailscale&&(h.tailscale=r.tailscale)}}catch{}const t=document.getElementById("quick-ip-lan"),e=document.getElementById("quick-ip-tailscale");t&&(h.lan?(t.dataset.ip=h.lan,t.textContent=`LAN (${h.lan})`,t.title=`LAN IP: ${h.lan}`):t Line truncated
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${x.self?.hostname} (${x.self?.ip})</span>
`,g.disabled=!1):x.installed?(r.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',g.disabled=!0):(r.innerHTML='<span style="color: var(--muted);">Not available</span>',g.disabled=!0)}catch{r.innerHTML='<span style="color: var(--muted);">Could not check</span>',g.disabled=!0}g.checked=!1,f()}function c(){const t=document.getElementById("service-type-local"),e=document.getElementById("service-type-external"),s=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),r=document.getElementById("tab-local"),g=document.getElementById("tab-external");function m(){t.checked?(s.style.display="grid",n.style.display="none",r&&(r.style.background="var(--accent)",r.style.color="var(--bg)"),g&&(g.style.background="transparent",g.style.color="var(--muted)")):(s.style.display="none",n.style.display="block",g&&(g.style.background="var(--accent)",g.style.color="var(--bg)"),r&&(r.style.background="transparent",r.style.color="var(--muted)"))}t?.addEventListener("change",m),e?.addEventListener("change",m)}function p(){const t=document.getElementById("service-name-input"),e=document.getElementById("service-subdomain-input"),s=document.getElementById("subdomain-preview");let n=!1;t?.addEventListener("input",()=>{const L=l(t.value);!n&&e&&(e.value=L),s&&(s.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()}),e?.addEventListener("input",()=>{n=e.value!==l(t?.value||"");const L=e.value.trim()||l(t?.value||"");s&&(s.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()});const r=document.getElementById("external-service-name"),g=document.getElementById("external-service-subdomain"),m=document.getElementById("external-subdomain-preview"),x=document.getElementById("external-domain-preview");let I=!1;r?.addEventListener("input",()=>{const L=l(r.value);!I&&g&&(g.value=L);const P=g?.value||L;m&&(m.textContent=P?`\u2192 ${buildDomain(P)}`:""),x&&(x.textContent=P?buildDomain(P):"")}),g?.addEventListener("input",()=>{I=g.value!==l(r?.value||"");const L=g.value.trim()||l(r?.value||"");m&&(m.textContent=L?`\u2192 ${buildDomain(L)}`:""),x&&(x.textContent=L?buildDomain(L):"")})}async function d(){const t=document.getElementById("external-service-name").value.trim(),e=document.getElementById("external-service-url").value.trim(),s=(document.getElementById("external-service-subdomain").value.trim()||l(t)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),r=document.getElementById("external-service-icon").value.trim(),g=document.getElementById("external-create-dns").checked,m=document.getElementById("external-create-caddy").checked,x=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",I=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!t||!e){showNotification("Please fill in Name and External URL","warning");return}if(!s){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!e.startsWith("http://")&&!e.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(s);try{const B={dns:null,caddy:null,dashboard:!1};if(g)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:x,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(k){B.dns=k.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(m)try{const T={subdomain:s,externalUrl:e,preserveHost:I,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(T)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(T){B.caddy=T.message}const $={id:s,name:t,url:`https://${C}`,externalUrl:e,logo:n||r||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const E=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(T=>!E.includes(T.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(T){console.warn("Failed to save to services.json:",T)}window.buildGrid(),window.refreshAll(),o();const S=[`External service "${t}" added!`];g&&S.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),m&&S.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),S.push(`Access at: https://${C}`),showNotification(S.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI Line truncated
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span>
`,f.disabled=!1):w.installed?(r.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',f.disabled=!0):(r.innerHTML='<span style="color: var(--muted);">Not available</span>',f.disabled=!0)}catch{r.innerHTML='<span style="color: var(--muted);">Could not check</span>',f.disabled=!0}f.checked=!1,m()}function u(){const t=document.getElementById("service-type-local"),e=document.getElementById("service-type-external"),i=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),r=document.getElementById("tab-local"),f=document.getElementById("tab-external");function p(){t.checked?(i.style.display="grid",n.style.display="none",r&&(r.style.background="var(--accent)",r.style.color="var(--bg)"),f&&(f.style.background="transparent",f.style.color="var(--muted)")):(i.style.display="none",n.style.display="block",f&&(f.style.background="var(--accent)",f.style.color="var(--bg)"),r&&(r.style.background="transparent",r.style.color="var(--muted)"))}t?.addEventListener("change",p),e?.addEventListener("change",p)}function l(){const t=document.getElementById("service-name-input"),e=document.getElementById("service-subdomain-input"),i=document.getElementById("subdomain-preview");let n=!1;t?.addEventListener("input",()=>{const L=c(t.value);!n&&e&&(e.value=L),i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),m()}),e?.addEventListener("input",()=>{n=e.value!==c(t?.value||"");const L=e.value.trim()||c(t?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),m()});const r=document.getElementById("external-service-name"),f=document.getElementById("external-service-subdomain"),p=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let I=!1;r?.addEventListener("input",()=>{const L=c(r.value);!I&&f&&(f.value=L);const P=f?.value||L;p&&(p.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),f?.addEventListener("input",()=>{I=f.value!==c(r?.value||"");const L=f.value.trim()||c(r?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function a(){const t=document.getElementById("external-service-name").value.trim(),e=document.getElementById("external-service-url").value.trim(),i=(document.getElementById("external-service-subdomain").value.trim()||c(t)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),r=document.getElementById("external-service-icon").value.trim(),f=document.getElementById("external-create-dns").checked,p=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",I=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!t||!e){showNotification("Please fill in Name and External URL","warning");return}if(!i){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!e.startsWith("http://")&&!e.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(i);try{const B={dns:null,caddy:null,dashboard:!1};if(f)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(k){B.dns=k.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(p)try{const T={subdomain:i,externalUrl:e,preserveHost:I,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(T)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(T){B.caddy=T.message}const $={id:i,name:t,url:`https://${C}`,externalUrl:e,logo:n||r||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const E=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(T=>!E.includes(T.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(T){console.warn("Failed to save to services.json:",T)}window.buildGrid(),window.refreshAll(),o();const S=[`External service "${t}" added!`];f&&S.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),p&&S.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),S.push(`Access at: https://${C}`),showNotification(S.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI Line truncated
+2 -2
View File
@@ -1,4 +1,4 @@
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(n.success&&Line truncated
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(()=>{window._dcAuthLost||window.refreshAll()},DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-stoLine truncated
.dc-monitor {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -329,4 +329,4 @@
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
</div>
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBhbmltYXRlVG9wQ2FyZHMoKTtcbiAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIHNldEludGVydmFsKHdpbmRvdy5yZWZyZXNoQWxsLCBEQy5QT0xMLkRBU0hCT0FSRCk7XG4gICAgaWYgKHR5cGVvZiB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hDcmVkc0J1dHRvbnMoKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMgPT09ICdmdW5jdGlvbicpIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMoKTtcbiAgICAvLyBVcGRhdGUgYXV0aCBjYXJkIChtYXkgaGF2ZSBhbHJlYWR5IGJlZW4gdXBkYXRlZCBieSB0aGUgYXV0by1sb2FkIElJRkUgYnV0IGVuc3VyZSBpdCdzIGNvcnJlY3QpXG4gICAgaWYgKHR5cGVvZiB3aW5kb3cuX3VwZGF0ZUF1dGhDYXJkID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBjb25zdCByID0gYXdhaXQgZmV0Y2goJy9hcGkvdjEvdG90cC9jb25maWcnLCB7IGNhY2hlOiAnbm8tc3RvcmUnIH0pO1xuICAgICAgICBjb25zdCBkID0gYXdhaXQgci5qc29uKCk7XG4gICAgICAgIGlmIChkLnN1Y2Nlc3MpIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQoZC5jb25maWcuZW5hYmxlZCAmJiBkLmNvbmZpZy5pc1NldFVwLCBkLmNvbmZpZy5zZXNzaW9uRHVyYXRpb24pO1xuICAgICAgfSBjYXRjaCAoZSkgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICBpZiAod2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZCkge1xuICAgICAgdHJ5IHtcbiAgICAgICAgYXdhaXQgd2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZDtcbiAgICAgIH0gY2F0Y2ggKF8pIHsgLyogaWdub3JlICovIH1cbiAgICB9XG4gICAgLy8gTGF6eS1sb2FkIG9uYm9hcmRpbmcgb25seSBvbmNlIHBlciBpbnN0YWxsLCBvdGhlcndpc2UganVzdCBhZGQgdGhlIHRvdXIgYnV0dG9uXG4gICAgYWRkVG91ckJ1dHRvbigpO1xuICAgIGlmIChzaG91bGRMb2FkT25ib2FyZGluZygpKSB7XG4gICAgICBsb2FkT25ib2FyZGluZygpO1xuICAgIH1cblxuICAgIC8vIERDLTA0ODogaW5qZWN0IHRoZSBcIkFkbWluXCIgdHJpZ2dlciBidXR0b24gaW50byB0aGUgdG9wIGJhci4gVGhlXG4gICAgLy8gYnV0dG9uIG9ubHkgcmVuZGVycyB3aGVuIC9tZSByZXR1cm5zIGlzQWRtaW49dHJ1ZTsgdGhlIG1vZHVsZVxuICAgIC8vIHJlLWNoZWNrcyBldmVyeSA2MHMgc28gYSBwZXJtaXNzaW9uIGRvd25ncmFkZSB0YWtlcyBlZmZlY3QuXG4gICAgaWYgKHdpbmRvdy5BZG1pblBhbmVsICYmIHR5cGVvZiB3aW5kb3cuQWRtaW5QYW5lbC5hdHRhY2hUcmlnZ2VyID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBhd2FpdCB3aW5kb3cuQWRtaW5QYW5lbC5hdHRhY2hUcmlnZ2VyKGRvY3VtZW50LmJvZHkpO1xuICAgICAgfSBjYXRjaCAoZ Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBhbmltYXRlVG9wQ2FyZHMoKTtcbiAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIHNldEludGVydmFsKCgpID0+IHtcbiAgICAgIC8vIFN0b3AgcG9sbGluZyBpZiB0aGUgc2Vzc2lvbiBoYXMgYmVlbiBpbnZhbGlkYXRlZCAoZS5nLiBUT1RQIGdhdGVcbiAgICAgIC8vIG5vdyBhY3RpdmUsIG9yIHVzZXIgbG9nZ2VkIG91dCkuIEF2b2lkcyByZWxlbnRsZXNzIDQwMS80MDMgbm9pc2UuXG4gICAgICBpZiAod2luZG93Ll9kY0F1dGhMb3N0KSByZXR1cm47XG4gICAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIH0sIERDLlBPTEwuREFTSEJPQVJEKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoQ3JlZHNCdXR0b25zID09PSAnZnVuY3Rpb24nKSB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucygpO1xuICAgIGlmICh0eXBlb2Ygd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cygpO1xuICAgIC8vIFVwZGF0ZSBhdXRoIGNhcmQgKG1heSBoYXZlIGFscmVhZHkgYmVlbiB1cGRhdGVkIGJ5IHRoZSBhdXRvLWxvYWQgSUlGRSBidXQgZW5zdXJlIGl0J3MgY29ycmVjdClcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQgPT09ICdmdW5jdGlvbicpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHIgPSBhd2FpdCBmZXRjaCgnL2FwaS92MS90b3RwL2NvbmZpZycsIHsgY2FjaGU6ICduby1zdG9yZScgfSk7XG4gICAgICAgIGNvbnN0IGQgPSBhd2FpdCByLmpzb24oKTtcbiAgICAgICAgaWYgKGQuc3VjY2Vzcykgd2luZG93Ll91cGRhdGVBdXRoQ2FyZChkLmNvbmZpZy5lbmFibGVkICYmIGQuY29uZmlnLmlzU2V0VXAsIGQuY29uZmlnLnNlc3Npb25EdXJhdGlvbik7XG4gICAgICB9IGNhdGNoIChlKSB7IC8qIGlnbm9yZSAqLyB9XG4gICAgfVxuICAgIGlmICh3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkKSB7XG4gICAgICB0cnkge1xuICAgICAgICBhd2FpdCB3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkO1xuICAgICAgfSBjYXRjaCAoXykgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICAvLyBMYXp5LWxvYWQgb25ib2FyZGluZyBvbmx5IG9uY2UgcGVyIGluc3RhbGwsIG90aGVyd2lzZSBqdXN0IGFkZCB0aGUgdG91ciBidXR0b25cbiAgICBhZGRUb3VyQnV0dG9uKCk7XG4gICAgaWYgKHNob3VsZExvYWRPbmJvYXJkaW5nKCkpIHtcbiAgICAgIGxvYWRPbmJvYXJkaW5nKCk7XG4gICAgfVxuXG4gICAgLy8gREMtMDQ4OiBpbmplY3QgdGhlIFwiQWRtaW5cIiB0cmlnZ2VyIGJ1dHRvbiBpbnRvIHRoZSB0b3AgYmFyLiBUaGVcbiAgICAvLyBidXR0b24gb25seSByZW5kZXJzIHdoZW4gL21lIHJldHVybnMgaXNBZG1pbj10cnVlOyB0aGUgbW9kdWxlXG4gICAgLy8gcmUtY2hlY2tzIGV2ZXJ5IDYwcyBzbyBhI Line truncated
+10 -9
View File
@@ -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>
+137 -18
View File
@@ -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');
@@ -137,4 +256,4 @@
showNotification('Error: ' + e.message, 'error');
}
});
})();
})();
+18 -1
View File
@@ -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;
}
}
})();
+6 -1
View File
@@ -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)
+10 -1
View File
@@ -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>
+85 -13
View File
@@ -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>';
}
@@ -49,7 +119,7 @@
try {
const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showNotification('✅ Error logs cleared', 'success', 3000);
loadErrorLogs();
@@ -68,5 +138,7 @@
refreshBtn?.addEventListener('click', loadErrorLogs);
clearBtn?.addEventListener('click', clearErrorLogs);
levelSelect?.addEventListener('change', loadErrorLogs);
tailSelect?.addEventListener('change', loadErrorLogs);
wireModal(modal, closeBtn);
})();
})();
+36 -1
View File
@@ -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;
})();
+172
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)}&timestamps=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');
})();
+9
View File
@@ -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';
+99
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-4a75cb88af';
const CACHE = 'dashcaddy-shell-78eab743c2';
const PRECACHE = [
'/',
'/index.html',