Compare commits

..
1 Commits
28 changed files with 916 additions and 4023 deletions

No files matched your search

@@ -379,235 +379,11 @@ describe('CaddyUpstreamWatcher', () => {
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);
});
// ---- Loopback → host-gateway remap (live bug 2026-08-18) -----------------
// The watcher runs inside the container; Caddyfile `localhost:PORT` means
// the HOST's loopback. Probing the container's own loopback gave 278
// phantom failures per healthy host-side upstream.
test('loopback upstream probe is remapped to host.docker.internal (not container loopback)', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
expect(u).toBeTruthy();
const http = require('http');
await w._probeOne(u);
// The probe request must have gone to host.docker.internal, keeping the port.
const call = http.request.mock.calls.find(c => c[0].hostname === 'host.docker.internal');
expect(call).toBeTruthy();
expect(call[0].port).toBe('8088');
// Display key is unchanged.
expect(w.snapshot().upstreams[0].host).toBe('localhost:8088');
expect(w.snapshot().upstreams[0].status).toBe('up');
});
test('127.0.0.1 and 127.x addresses are also remapped', async () => {
seedSites({
'a.sami': 'a.sami { reverse_proxy 127.0.0.1:8765 }\n',
'b.sami': 'b.sami { reverse_proxy 127.0.0.53:9000 }\n'
});
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
for (const u of w.upstreams.values()) await w._probeOne(u);
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
expect(hostnames).toEqual(['host.docker.internal', 'host.docker.internal']);
});
test('non-loopback upstreams are probed at their literal address (no remap)', async () => {
seedSites({ 'arch.sami': 'arch.sami { reverse_proxy 100.120.159.34:5000 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
await w._probeOne(w.upstreams.get('100.120.159.34:5000'));
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
expect(hostnames).toEqual(['100.120.159.34']);
});
test('failed host-gateway probe marks loopback upstream unverifiable — no failures, no incident', async () => {
// Services bound to 127.0.0.1 on the host refuse bridge-IP connections;
// from inside the container that is indistinguishable from "dead", and
// Caddy (on the host) still routes fine — so it must NOT count as down.
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED 172.17.0.1:8088' });
const { w } = loadWatcher();
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
w.healthChecker = fakeHealthChecker;
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
u.lastSuccessAt = new Date(Date.now() - 10 * 60 * 1000).toISOString(); // long "failing" history
await w._probeOne(u);
const snap = w.snapshot().upstreams[0];
expect(snap.status).toBe('unverifiable');
expect(snap.consecutiveFailures).toBe(0);
expect(snap.dead).toBe(false);
expect(snap.failingForMs).toBe(0);
expect(snap.lastError).toMatch(/not verifiable from container/);
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
});
test('unverifiable sorts between muted and up in the snapshot', async () => {
seedSites({
'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n',
'b.sami': 'b.sami { reverse_proxy localhost:9876 }\n',
'c.sami': 'c.sami { reverse_proxy 2.2.2.2:80 }\n'
});
const { w } = loadWatcher();
await w.scanSites();
const all = Array.from(w.upstreams.values());
all.find(u => u.host === '1.1.1.1:80').status = 'up';
all.find(u => u.host === 'localhost:9876').status = 'unverifiable';
w.muted.add('2.2.2.2:80');
const order = w.snapshot().upstreams.map(u => u.host);
expect(order).toEqual(['2.2.2.2:80', 'localhost:9876', '1.1.1.1:80']);
});
// ---- verifiedViaBridge (DC-053 follow-up item 2b-a) ----------------------
// A loopback upstream whose PRIOR probe succeeded via host-gateway proves
// the bridge CAN reach the host. If a later probe then fails, that is
// near-conclusive evidence the upstream itself went dead — not that
// bridge connectivity broke. Restore dead-detection for that subset.
test('successful host-gateway probe sets verifiedViaBridge=true on loopback upstream', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
expect(u.verifiedViaBridge).toBeFalsy();
await w._probeOne(u);
expect(u.verifiedViaBridge).toBe(true);
expect(u.status).toBe('up');
expect(w.snapshot().upstreams[0].verifiedViaBridge).toBe(true);
});
test('loopback upstream with verifiedViaBridge fails -> counts as down (not unverifiable)', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
// First probe succeeds (sets verifiedViaBridge), second probe fails.
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED 172.17.0.1:8088' });
const { w } = loadWatcher();
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
w.healthChecker = fakeHealthChecker;
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
await w._probeOne(u);
expect(u.verifiedViaBridge).toBe(true);
expect(u.status).toBe('up');
await w._probeOne(u);
expect(u.status).toBe('down');
expect(u.consecutiveFailures).toBe(1);
expect(u.lastError).toMatch(/ECONNREFUSED/);
// Snapshot also reflects verifiedViaBridge so dashboard can label it.
const snap = w.snapshot().upstreams[0];
expect(snap.verifiedViaBridge).toBe(true);
// No incident yet — needs DEAD_AFTER_MS of continuous failure.
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
});
test('loopback upstream with verifiedViaBridge eventually opens a dead incident', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 }); // probe 1: success -> verifiedViaBridge
probeQueue.push({ kind: 'err', message: 'down' });
const { w } = loadWatcher();
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
w.healthChecker = fakeHealthChecker;
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
await w._probeOne(u);
// Pre-set lastSuccessAt to DEAD_AFTER_MS ago so the second failure
// immediately crosses the 5-minute threshold.
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
await w._probeOne(u);
expect(fakeHealthChecker.createIncident).toHaveBeenCalledWith(
'localhost:8088',
'caddy-upstream-dead',
expect.stringMatching(/unreachable for 6m/),
expect.objectContaining({ status: 'down', serviceId: 'localhost:8088' })
);
});
// ---- IN_CONTAINER=false kill-switch (DC-053 follow-up item 2b-b) --------
// When the API runs bare-metal (or in a sidecar next to Caddy), the
// loopback host IS the host — no bridge. Probing loopback verbatim
// gives real, conclusive evidence.
test('IN_CONTAINER=false disables host-gateway remap (probes loopback verbatim)', async () => {
process.env.IN_CONTAINER = 'false';
try {
seedSites({
'a.sami': 'a.sami { reverse_proxy localhost:8088 }\n',
'b.sami': 'b.sami { reverse_proxy 127.0.0.1:9000 }\n',
'c.sami': 'c.sami { reverse_proxy 1.2.3.4:80 }\n' // non-loopback, should still go literal
});
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'ok', statusCode: 200 });
// Force module reload so the new IN_CONTAINER is picked up at require time.
jest.resetModules();
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
for (const u of w.upstreams.values()) await w._probeOne(u);
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
// All three go to their literal addresses — no host.docker.internal.
expect(hostnames).toEqual(['localhost', '127.0.0.1', '1.2.3.4']);
// And no upstream is marked verifiedViaBridge (the loopback-success
// gate only matters in the bridge case).
for (const u of w.upstreams.values()) {
expect(u.verifiedViaBridge).toBeFalsy();
}
} finally {
delete process.env.IN_CONTAINER;
}
});
test('IN_CONTAINER unset (default) still uses host-gateway remap', async () => {
delete process.env.IN_CONTAINER;
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
jest.resetModules();
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
await w._probeOne(w.upstreams.get('localhost:8088'));
const call = http.request.mock.calls.find(c => c[0].hostname === 'host.docker.internal');
expect(call).toBeTruthy();
});
// ---- verifiedViaBridge persistence (B-grade polish) -----------------------
// GLM judge LOW: don't re-prove bridge connectivity across container
// restarts. A previously-positive observation is still good evidence.
test('verifiedViaBridge survives a save -> reload cycle (state.json round-trip)', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 }); // probe succeeds -> verifiedViaBridge=true
const { w: w1 } = loadWatcher();
await w1.scanSites();
const u = w1.upstreams.get('localhost:8088');
await w1._probeOne(u);
expect(u.verifiedViaBridge).toBe(true);
// Force a save.
w1._saveState();
// Reload from the same file via a fresh watcher instance.
jest.resetModules();
const { w: w2 } = loadWatcher();
await w2.scanSites();
const restored = w2.upstreams.get('localhost:8088');
expect(restored).toBeTruthy();
expect(restored.verifiedViaBridge).toBe(true);
// The snapshot field carries it through too.
expect(w2.snapshot().upstreams[0].verifiedViaBridge).toBe(true);
});
});
@@ -322,89 +322,6 @@ describe('CSRF Protection', () => {
process.env.NODE_ENV = origEnv;
});
// DC-058: differentiate "browser auto-retry" from "real probe" by the
// presence of the X-CSRF-Token header. The 403 response is identical in
// both branches; only the stderr log tag changes.
describe('DC-058: browser-auto-retry vs real-probe log tagging', () => {
let stderrSpy;
let origEnv;
beforeEach(() => {
origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
process.env.NODE_ENV = origEnv;
stderrSpy.mockRestore();
});
it('tags missing-cookie with [CSRF-debug] when X-CSRF-Token header also present (browser auto-retry)', () => {
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
});
csrfValidationMiddleware(req, res, next);
// 403 response unchanged
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('DC-100') })
);
// Log tag is [CSRF-debug]
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF-debug]');
expect(lastWrite).toContain('browser auto-retry');
expect(lastWrite).not.toMatch(/^\[CSRF\][^-]/); // not bare [CSRF]
});
it('tags missing-cookie with [CSRF] when no X-CSRF-Token header present (real probe)', () => {
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '' }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF]');
expect(lastWrite).not.toContain('[CSRF-debug]');
expect(lastWrite).not.toContain('browser auto-retry');
});
it('keeps [CSRF] tag when cookie present but header missing (curl probe with manual cookie)', () => {
const nonce = generateToken();
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF]');
expect(lastWrite).not.toContain('[CSRF-debug]');
});
it('headerToken is read from x-csrf-token (lowercased by Express) — lowercase header triggers [CSRF-debug]', () => {
// Express/Node lowercases all incoming header keys, so production code
// only ever sees lowercase. We test the exact code path here.
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF-debug]');
});
});
});
describe('renewCSRFToken', () => {
@@ -0,0 +1,135 @@
// Unit tests for the fixed /api/v1/error-logs parser
// (route /opt/dashcaddy/dashcaddy-api/routes/errorlogs.js)
//
// Background: the prior implementation split on '='.repeat(80) but the
// unified logger writes \u2500 horizontal-rule separators. As a result
// every modal-open returned ZERO entries — same class of silent bug as
// DC-050 (audit log). These tests pin the new behavior so future refactors
// can't reintroduce it.
const { parseEntries, readTailBytes, MAX_TAIL } = require('../routes/errorlogs');
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
const SEP = '\n' + '\u2500'.repeat(72) + '\n';
function buildLog(entries) {
return entries.map((e, i) => {
const head = `[${e.timestamp}] [${e.level}] ${e.context}: ${e.message}`;
return head + (e.details ? '\n' + e.details : '') + SEP;
}).join('');
}
describe('errorlogs parser (DC-051)', () => {
test('parses single entry with U+2500 separator', () => {
const text = buildLog([{
timestamp: '2026-08-16T23:13:14.123Z',
level: 'ERR',
context: '/api/v1/templates',
message: 'Route GET /v1/templates not found',
details: 'NotFoundError: Route GET /v1/templates not found\n at notFoundHandler (/app/src/utilities/error-handler.js:71:8)',
}]);
const out = parseEntries(text);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
timestamp: '2026-08-16T23:13:14.123Z',
level: 'ERR',
context: '/api/v1/templates',
message: 'Route GET /v1/templates not found',
});
expect(out[0].details).toContain('notFoundHandler');
expect(out[0].details).not.toContain('\u2500');
});
test('returns multiple entries in order, ignoring separator residue', () => {
const text = buildLog([
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'first' },
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'WRN', context: 'b', message: 'second' },
{ timestamp: '2026-08-16T23:02:00.000Z', level: 'INF', context: 'c', message: 'third', details: 'extra' },
]);
const out = parseEntries(text);
expect(out.map(e => e.context)).toEqual(['a', 'b', 'c']);
expect(out[1].level).toBe('WRN');
expect(out[2].details).toBe('extra');
});
test('skips malformed lines without throwing', () => {
const text = 'this is not a log entry\n' + SEP + '[2026-08-16T23:00:00.000Z] [ERR] x: y\n' + SEP;
const out = parseEntries(text);
expect(out).toHaveLength(1);
expect(out[0].message).toBe('y');
});
test('empty input returns empty array', () => {
expect(parseEntries('')).toEqual([]);
expect(parseEntries(' \n\n ')).toEqual([]);
});
test('regression: would have returned 0 entries under the OLD splitter', () => {
// Old impl: text.split('='.repeat(80)).filter(...). That produced one
// big block, parser rejected all headers, returned ZERO entries. New
// impl must NOT regress to that behavior on a real-format log.
const text = buildLog([
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'm' },
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'ERR', context: 'b', message: 'm' },
]);
// Sanity: the old split would produce 1 block (no '=' in the text).
expect(text.split('='.repeat(80))).toHaveLength(1);
// New parser must surface both entries.
expect(parseEntries(text)).toHaveLength(2);
});
test('MAX_TAIL is bounded (>=100, <=1000) — prevents unbounded read', () => {
expect(MAX_TAIL).toBeGreaterThanOrEqual(100);
expect(MAX_TAIL).toBeLessThanOrEqual(1000);
});
});
describe('errorlogs readTailBytes (DC-051)', () => {
let tmpFile;
beforeAll(async () => {
tmpFile = path.join(os.tmpdir(), `dc-051-errorlog-${process.pid}.log`);
const entries = [];
for (let i = 0; i < 50; i++) {
entries.push({
timestamp: `2026-08-16T23:${String(i % 60).padStart(2,'0')}:00.000Z`,
level: i % 2 === 0 ? 'ERR' : 'WRN',
context: `ctx-${i}`,
message: `message body ${i}`,
details: i % 3 === 0 ? `stack for ${i}` : null,
});
}
await fsp.writeFile(tmpFile, buildLog(entries));
});
afterAll(async () => {
try { await fsp.unlink(tmpFile); } catch {}
});
test('returns parsed entries within the byte budget', async () => {
const { text, totalSize, truncated } = await readTailBytes(tmpFile, 4 * 1024);
expect(typeof totalSize).toBe('number');
expect(typeof truncated).toBe('boolean');
const parsed = parseEntries(text);
expect(parsed.length).toBeGreaterThan(0);
// Should never include partial first line — every parsed entry has a real timestamp.
for (const e of parsed) {
expect(e.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
});
test('truncates when file exceeds byte budget', async () => {
const stat = await fsp.stat(tmpFile);
const smallBudget = Math.floor(stat.size / 4);
const { truncated } = await readTailBytes(tmpFile, smallBudget);
expect(truncated).toBe(true);
});
test('does not truncate when file fits within byte budget', async () => {
const stat = await fsp.stat(tmpFile);
const bigBudget = stat.size * 2;
const { truncated } = await readTailBytes(tmpFile, bigBudget);
expect(truncated).toBe(false);
});
});
@@ -1,326 +0,0 @@
/**
* DC-055: Host journald reader unit tests
*
* The reader is a security-sensitive shell-out — every test below exists
* to prevent a regression that would let a caller pass a tainted unit
* name or since/until/search string to journalctl. We never call the real
* binary; every spawn is mocked by injecting an `exec` function (the
* module accepts exec as the second argument specifically for testability).
*/
const path = require('path');
const { EventEmitter } = require('events');
const MODULE_PATH = path.join(__dirname, '..', 'src', 'monitoring', 'journald-reader.js');
// Construct a fake child process that matches the interface journald-reader
// uses: stdout/stderr EventEmitters, kill(), and emits 'exit' on demand.
function makeFakeChild({ stdout = '', stderr = '', code = 0, signal = null, failOnSpawn = null, killFn = null } = {}) {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = killFn || (() => {});
process.nextTick(() => {
if (failOnSpawn) {
const err = new Error('spawn fail');
err.code = failOnSpawn;
child.emit('error', err);
return;
}
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
child.emit('exit', code, signal);
});
return child;
}
// Factory for an `exec` function that returns the given fake child.
function fakeExec(child) {
return jest.fn().mockReturnValue(child);
}
describe('journald-reader', () => {
describe('assertUnitAllowed', () => {
const { assertUnitAllowed } = require(MODULE_PATH);
test('accepts allow-listed bare names', () => {
expect(assertUnitAllowed('caddy')).toBe('caddy');
expect(assertUnitAllowed('docker')).toBe('docker');
expect(assertUnitAllowed('dashcaddy-api')).toBe('dashcaddy-api');
});
test('strips .service suffix', () => {
expect(assertUnitAllowed('caddy.service')).toBe('caddy');
expect(assertUnitAllowed('docker.service')).toBe('docker');
});
test('rejects units not on the allow-list', () => {
expect(() => assertUnitAllowed('sshd')).toThrow(/not in allow-list/);
expect(() => assertUnitAllowed('nginx')).toThrow(/not in allow-list/);
expect(() => assertUnitAllowed('root')).toThrow(/not in allow-list/);
});
test('rejects shell metacharacters and path traversal', () => {
expect(() => assertUnitAllowed('caddy; rm -rf /')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('caddy && touch /tmp/pwn')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('caddy|tee /etc/passwd')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('../etc/passwd')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('caddy\nfoo')).toThrow(/invalid characters/);
});
test('rejects empty / non-string', () => {
expect(() => assertUnitAllowed('')).toThrow(/unit is required/);
expect(() => assertUnitAllowed(null)).toThrow(/unit is required/);
expect(() => assertUnitAllowed(undefined)).toThrow(/unit is required/);
expect(() => assertUnitAllowed(42)).toThrow(/unit is required/);
});
test('throws ValidationError specifically (route layer keys on .name)', () => {
try { assertUnitAllowed('nginx'); }
catch (e) { expect(e.name).toBe('ValidationError'); }
});
});
describe('parseTail', () => {
const { parseTail, MAX_TAIL_LINES } = require(MODULE_PATH);
test('returns fallback on undefined', () => {
expect(parseTail(undefined)).toBe(200);
expect(parseTail(undefined, 50)).toBe(50);
});
test('clamps to MAX_TAIL_LINES', () => {
expect(parseTail('999999999999')).toBe(MAX_TAIL_LINES);
expect(parseTail(999999999999)).toBe(MAX_TAIL_LINES);
});
test('rejects non-positive and non-integer', () => {
expect(() => parseTail('0')).toThrow(/positive integer/);
expect(() => parseTail('-5')).toThrow(/positive integer/);
expect(() => parseTail('abc')).toThrow(/positive integer/);
expect(() => parseTail('1.5')).toThrow(/positive integer/);
expect(() => parseTail(NaN)).toThrow(/positive integer/);
});
test('accepts valid integers', () => {
expect(parseTail('1')).toBe(1);
expect(parseTail('500')).toBe(500);
expect(parseTail(200)).toBe(200);
});
});
describe('parseTimestamp', () => {
const { parseTimestamp } = require(MODULE_PATH);
test('returns null on undefined/empty', () => {
expect(parseTimestamp(undefined, 'since')).toBeNull();
expect(parseTimestamp('', 'since')).toBeNull();
expect(parseTimestamp(null, 'since')).toBeNull();
});
test('parses ISO 8601 timestamps', () => {
const out = parseTimestamp('2026-08-18T07:00:00Z', 'since');
expect(out).toBe('2026-08-18T07:00:00.000Z');
});
test('parses ISO date-only', () => {
const out = parseTimestamp('2026-08-18', 'since');
expect(out).toMatch(/^2026-08-18/);
});
test('parses unix epoch in seconds and ms', () => {
// Use a known epoch so the test isn't sensitive to "now". The
// expected ISO output is computed at runtime so this stays correct.
const epochSec = 1787038846; // 2026-08-18T07:00:46Z
const expected = new Date(epochSec * 1000).toISOString();
expect(parseTimestamp(String(epochSec), 'since')).toBe(expected);
expect(parseTimestamp(String(epochSec * 1000), 'since')).toBe(expected);
});
test('passes through journalctl relative syntax', () => {
expect(parseTimestamp('30 min ago', 'since')).toBe('30 min ago');
expect(parseTimestamp('today', 'until')).toBe('today');
});
test('rejects shell metacharacters in relative syntax', () => {
expect(() => parseTimestamp('30 min ago; touch /tmp/pwn', 'since')).toThrow(/forbidden/);
expect(() => parseTimestamp('today && rm -rf /', 'until')).toThrow(/forbidden/);
});
test('rejects strings >1024 chars', () => {
const huge = 'a'.repeat(1025);
expect(() => parseTimestamp(huge, 'since')).toThrow(/forbidden/);
});
test('rejects invalid ISO', () => {
// 'not-a-date' doesn't match the ISO_PATTERN and isn't numeric or
// safe relative-syntax — falls through to the relative branch but
// doesn't contain forbidden chars either, so it would pass through
// to journalctl. Use a string with shell metacharacters instead
// to prove the path actually rejects.
expect(() => parseTimestamp('yesterday | nc evil 1234', 'since')).toThrow();
// Numbers that overflow Date.parse
expect(() => parseTimestamp('99999999999999999999', 'since')).toThrow();
});
});
describe('buildArgv', () => {
const { buildArgv } = require(MODULE_PATH);
test('always emits --directory + unit + --no-pager', () => {
const argv = buildArgv({ unit: 'caddy', tail: 100 });
expect(argv).toContain('--directory');
expect(argv[argv.indexOf('--directory') + 1]).toBe('/var/log/journal');
expect(argv).toContain('--no-pager');
expect(argv).toContain('-u');
expect(argv[argv.indexOf('-u') + 1]).toBe('caddy');
expect(argv).not.toContain('--follow');
});
test('follow flag is set when requested', () => {
const argv = buildArgv({ unit: 'caddy', follow: true });
expect(argv).toContain('--follow');
});
test('emits -n <tail> for numeric tail', () => {
const argv = buildArgv({ unit: 'caddy', tail: 500 });
const idx = argv.indexOf('-n');
expect(idx).toBeGreaterThan(-1);
expect(argv[idx + 1]).toBe('500');
});
test('emits --since/--until/search when provided', () => {
const argv = buildArgv({
unit: 'caddy', tail: 100,
since: '2026-08-18T00:00:00Z',
until: '2026-08-18T23:59:59Z',
search: 'health',
});
expect(argv).toContain('--since');
expect(argv).toContain('--until');
expect(argv).toContain('-S');
expect(argv[argv.indexOf('-S') + 1]).toBe('health');
});
test('emits argv as a flat string array (no shell)', () => {
const argv = buildArgv({ unit: 'caddy', tail: 1 });
expect(argv.every(a => typeof a === 'string')).toBe(true);
});
});
describe('readEntries', () => {
const reader = require(MODULE_PATH);
test('parses short-output lines into structured entries', async () => {
const child = makeFakeChild({
stdout: [
'Aug 18 00:42:46 vmi3080415 caddy[3620580]: {"level":"info","msg":"hello"}',
'Aug 18 00:42:56 vmi3080415 caddy[3620580]: {"level":"warn","msg":"oops"}',
'',
].join('\n'),
});
const entries = await reader.readEntries({ unit: 'caddy', tail: 50 }, { exec: fakeExec(child) });
expect(entries).toHaveLength(2);
expect(entries[0].timestamp).toBe('Aug 18 00:42:46');
expect(entries[0].hostname).toBe('vmi3080415');
expect(entries[0].unit).toBe('caddy');
expect(entries[0].text).toBe('{"level":"info","msg":"hello"}');
});
test('throws on ValidationError for bad unit', async () => {
await expect(reader.readEntries({ unit: 'nginx' })).rejects.toMatchObject({
name: 'ValidationError',
});
});
test('throws on ValidationError for bad tail', async () => {
await expect(reader.readEntries({ unit: 'caddy', tail: -1 })).rejects.toMatchObject({
name: 'ValidationError',
});
});
test('throws on ValidationError for shell-meta since', async () => {
await expect(reader.readEntries({ unit: 'caddy', since: 'yesterday; touch /tmp/pwn' }))
.rejects.toMatchObject({ name: 'ValidationError' });
});
test('surfaces ENOENT as Error("journalctl unavailable")', async () => {
const child = makeFakeChild({ failOnSpawn: 'ENOENT' });
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
.then(() => null, e => e);
expect(err.message).toBe('journalctl unavailable');
});
test('surfaces non-zero exit with stderr snippet', async () => {
const child = makeFakeChild({
stdout: '',
stderr: 'Failed to open directory: /var/log/journal/foo\n',
code: 1,
});
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
.then(() => null, e => e);
expect(err.message).toMatch(/exited 1/);
expect(err.message).toMatch(/Failed to open directory/);
});
test('clamps stdout at MAX_OUTPUT_BUFFER and rejects with overflow', async () => {
// Use smaller chunks: 800KB then another 800KB = 1.6MB > 2MB cap.
// Wait, that's <2MB. Need: total > 2MB. Use 1MB + 1.2MB.
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
const cap = require(MODULE_PATH).MAX_OUTPUT_BUFFER;
const first = Math.floor(cap * 0.4); // 40%
const second = Math.floor(cap * 0.7); // 70% more — total 110%
process.nextTick(() => {
child.stdout.emit('data', Buffer.alloc(first, 'x'));
child.stdout.emit('data', Buffer.alloc(second, 'x'));
// Don't emit exit — the overflow rejection doesn't depend on it.
// Kill the child eventually so Jest can exit cleanly.
setTimeout(() => child.emit('exit', null, 'SIGKILL'), 50);
});
const execSpy = jest.fn().mockReturnValue(child);
const err = await reader.readEntries({ unit: 'caddy' }, { exec: execSpy })
.then(() => null, e => e);
expect(err).not.toBeNull();
expect(err.message).toMatch(/exceeded/);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
});
});
describe('streamEntries', () => {
const reader = require(MODULE_PATH);
test('emits parsed data + completes on exit', async () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from('Aug 18 00:42:46 host caddy[1]: hello\n'));
child.emit('exit', 0, null);
});
const seen = [];
const execSpy = jest.fn().mockReturnValue(child);
reader.streamEntries({ unit: 'caddy' }, {
exec: execSpy,
onData: (e) => seen.push(e),
onError: () => {},
});
// Drain microtasks so the nextTick callback fires.
await new Promise((r) => setTimeout(r, 30));
expect(execSpy).toHaveBeenCalledTimes(1);
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[0].unit).toBe('caddy');
expect(seen[0].text).toBe('hello');
});
test('rejects bad unit before opening stream', () => {
expect(() => reader.streamEntries({ unit: 'nginx' }, { onError: () => {} }))
.toThrow(/not in allow-list/);
});
});
});
@@ -1,218 +0,0 @@
/**
* DC-057: dead-shadow /backups/schedule handler removed.
*
* The duplicate `router.post('/backups/schedule', ...)` previously registered
* far below the canonical one was unreachable (Express matches the first
* registered handler per METHOD+PATH). It bypassed `premiumGating` and
* `validateBody` and used a `name`-keyed schema that would have corrupted the
* backup config if it ever ran. The canonical handler uses the error code
* `backups-schedule-update`; the dead handler used `backups-schedule-legacy`.
* This test proves:
*
* 1. The router registers exactly ONE POST /backups/schedule handler
* (the canonical, appId-keyed one).
* 2. No handler references the legacy "backups-schedule-legacy" error code.
* 3. The legacy "name"-keyed schema now produces a 400 ValidationError
* from the canonical Joi schema (dead handler is gone).
* 4. The canonical appId-keyed schema still succeeds (200).
* 5. premiumGating is enforced on the canonical POST.
*
* Mirrors the audit-log.routes.test.js pattern.
*/
const express = require('express');
function buildFakeBackupManager() {
const config = { backups: {}, defaultRetention: { keep: 7 } };
return {
getConfig: jest.fn(() => config),
updateConfig: jest.fn((next) => {
config.backups = next.backups || {};
}),
getHistory: jest.fn(() => []),
restoreBackup: jest.fn(async (id) => {
// Suppress require-await — keep async shape for parity with the
// real backupManager.restoreBackup contract.
return Promise.resolve({ id, status: 'restored' });
}),
};
}
function buildFakeLicenseManager() {
const requirePremium = jest.fn(() => (_req, _res, next) => next());
return {
requirePremium,
isPremium: jest.fn(() => true),
};
}
function buildRouter(licenseManager, backupManager) {
// Reset module cache so each test starts fresh
jest.resetModules();
const mod = require('../../routes/backups');
return mod({
backupManager,
licenseManager,
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
try { await fn(req, res, next); } catch (e) { next(e); }
},
});
}
function buildApp(router) {
// Catch-all error handler so ValidationError / NotFoundError become JSON
const app = express();
app.use(express.json());
app.use((req, res, next) => {
// intentionally strip auth — the test does not exercise it
next();
});
app.use('/', router);
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = err.statusCode || err.status || 500;
res.status(status).json({
error: err.message,
code: err.code || 'ERR',
});
});
return app;
}
function supertestFetch(app) {
// Tiny in-process fetch helper (no need to add supertest dep)
const http = require('http');
return function (method, path, body) {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const { port } = server.address();
const data = body ? JSON.stringify(body) : null;
const req = http.request({
method,
hostname: '127.0.0.1',
port,
path,
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
}, (res) => {
let chunks = '';
res.on('data', (c) => { chunks += c; });
res.on('end', () => {
server.close();
let parsed;
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.on('error', (e) => { server.close(); reject(e); });
if (data) req.write(data);
req.end();
});
});
};
}
describe('routes/backups POST /backups/schedule (DC-057)', () => {
let backupManager, licenseManager, app, fetch;
beforeEach(() => {
backupManager = buildFakeBackupManager();
licenseManager = buildFakeLicenseManager();
const router = buildRouter(licenseManager, backupManager);
app = buildApp(router);
fetch = supertestFetch(app);
});
test('registers exactly ONE POST /backups/schedule handler (canonical)', () => {
// Inspect the registered router layers and confirm only one POST /backups/schedule
// route exists (no shadowed / unreachable duplicate).
const router = buildRouter(licenseManager, backupManager);
const seen = [];
router.stack.forEach((layer) => {
if (layer.route && layer.route.path === '/backups/schedule' && layer.route.methods.post) {
seen.push(layer.route);
}
});
expect(seen).toHaveLength(1);
});
test('no handler references the legacy "backups-schedule-legacy" error code', () => {
// The canonical handler uses error code 'backups-schedule-update'.
// Walk the router stack and assert no route uses the legacy error code.
const router = buildRouter(licenseManager, backupManager);
const handlerStrings = [];
function walk(node) {
if (!node) return;
if (node.stack) node.stack.forEach(walk);
if (node.handle) {
const code = node.handle.toString();
handlerStrings.push(code);
}
}
walk(router);
const all = handlerStrings.join('\n');
expect(all).not.toContain('backups-schedule-legacy');
});
test('legacy name-keyed schema is REJECTED with 400 (dead route truly gone)', async () => {
// The dead handler accepted { name, schedule, maxStorageBytes, ...backupConfig }.
// After removal, the canonical Joi schema (backupScheduleCreate) rejects this
// shape because it requires `appId`. So we expect a 400.
const res = await fetch('POST', '/backups/schedule', {
name: 'mybackup',
schedule: 'daily',
maxStorageBytes: 1024,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/appId.*required|appId is required/i);
});
test('canonical appId-keyed schema SUCCEEDS (200) and writes backup config', async () => {
const res = await fetch('POST', '/backups/schedule', {
appId: 'plex',
schedule: 'daily',
retention: { keep: 7 },
destination: 'local',
destinationPath: '/var/backups/plex',
maxStorageBytes: 1024,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(backupManager.updateConfig).toHaveBeenCalledTimes(1);
const written = backupManager.updateConfig.mock.calls[0][0];
expect(written.backups).toHaveProperty('plex');
expect(written.backups.plex.schedule).toBe('daily');
expect(written.backups.plex.enabled).toBe(true);
expect(written.backups.plex.maxStorageBytes).toBe(1024);
});
test('premium gating is enforced on POST /backups/schedule', async () => {
// Replace the premium gate with one that 403s, then verify it runs.
licenseManager.requirePremium.mockReturnValueOnce(
(_req, res) => res.status(403).json({ error: 'premium required' }),
);
const router = buildRouter(licenseManager, backupManager);
app = buildApp(router);
fetch = supertestFetch(app);
const res = await fetch('POST', '/backups/schedule', {
appId: 'plex',
schedule: 'daily',
});
expect(res.status).toBe(403);
expect(backupManager.updateConfig).not.toHaveBeenCalled();
});
test('GET /backups/schedule still works (no collateral damage)', async () => {
const res = await fetch('GET', '/backups/schedule');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('schedules');
});
test('DELETE /backups/schedule/:appId still works', async () => {
// Seed the config so the delete has something to remove
backupManager.getConfig().backups.plex = { schedule: 'daily' };
const res = await fetch('DELETE', '/backups/schedule/plex');
expect(res.status).toBe(200);
expect(backupManager.updateConfig).toHaveBeenCalled();
});
});
@@ -1,277 +0,0 @@
/**
* DC-059: disk-space POST /config threshold-ordering invariant.
*
* DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the
* budget usage crosses, in the order
* cleanupAggressivePct → criticalThresholdPct → warningThresholdPct.
* If a caller writes the three thresholds out of order
* (e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher-
* priority branches become unreachable and the monitor silently
* misclassifies budget state — 'warning' would never fire even though the
* user set it as a threshold they care about.
*
* The fix lives in `routes/disk-space.js`: a `mergeAndCheckOrdering()`
* helper validates the *effective* (merged with live baseline) config
* against the invariant `warningThresholdPct < criticalThresholdPct <
* cleanupAggressivePct` BEFORE the route mutates diskSpaceMonitor.diskConfig.
*
* Tests cover:
* 1. Monotonic ascending order is accepted (happy path).
* 2. warningThresholdPct >= criticalThresholdPct is rejected with 400.
* 3. criticalThresholdPct >= cleanupAggressivePct is rejected with 400.
* 4. Partial updates work one field at a time without violating the
* invariant against the current baseline.
* 5. Out-of-bounds numeric values are clamped to the same bounds the
* original inline Math.min/Math.max chains enforced (50/60/70 → 99).
* 6. DiskSpaceMonitor.configure is NEVER called when the request is
* rejected (no partial mutation).
* 7. The merged config returned to the client is the post-clamp value,
* not the raw request body.
*/
const express = require('express');
const http = require('http');
const DEFAULT_CONFIG = {
enabled: true,
diskBudgetGB: 10,
warningThresholdPct: 80,
criticalThresholdPct: 90,
autoCleanup: true,
cleanupAggressivePct: 95,
};
function buildFakeDiskSpaceMonitor(initial = { ...DEFAULT_CONFIG }) {
const state = { ...initial };
return {
configure: jest.fn((updates) => {
Object.assign(state, updates);
return { ...state };
}),
getConfig: jest.fn(() => ({ ...state })),
getSnapshot: jest.fn(async () => ({})),
getDetailedBreakdown: jest.fn(async () => ({})),
performCleanup: jest.fn(async () => ({})),
// Test-only: peek at the internal state to confirm no mutation on rejection
_state: state,
};
}
function buildRouter(monitor) {
// Reset module cache so each test starts fresh
jest.resetModules();
const mod = require('../../routes/disk-space');
return mod({
diskSpaceMonitor: monitor,
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
try { await fn(req, res, next); } catch (e) { next(e); }
},
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
});
}
function buildApp(router) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { next(); }); // strip auth
app.use('/', router);
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = err.statusCode || err.status || 500;
res.status(status).json({
error: err.message,
code: err.code || 'ERR',
field: err.field || null,
});
});
return app;
}
function supertestFetch(app) {
return function (method, path, body) {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const { port } = server.address();
const data = body ? JSON.stringify(body) : null;
const req = http.request({
method,
hostname: '127.0.0.1',
port,
path,
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
}, (res) => {
let chunks = '';
res.on('data', (c) => { chunks += c; });
res.on('end', () => {
server.close();
let parsed;
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.on('error', (e) => { server.close(); reject(e); });
if (data) req.write(data);
req.end();
});
});
};
}
describe('routes/disk-space POST /config (DC-059 threshold ordering)', () => {
let monitor, app, fetch;
beforeEach(() => {
monitor = buildFakeDiskSpaceMonitor();
const router = buildRouter(monitor);
app = buildApp(router);
fetch = supertestFetch(app);
});
test('happy path — strict monotonic ascending order is accepted', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 75,
criticalThresholdPct: 88,
cleanupAggressivePct: 95,
});
expect(res.status).toBe(200);
expect(res.body.config).toEqual(expect.objectContaining({
warningThresholdPct: 75,
criticalThresholdPct: 88,
cleanupAggressivePct: 95,
}));
expect(monitor.configure).toHaveBeenCalledTimes(1);
});
test('warningThresholdPct >= criticalThresholdPct is rejected with 400', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 95,
criticalThresholdPct: 80,
cleanupAggressivePct: 99,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
expect(res.body.field).toBe('warningThresholdPct');
// Critical invariant: monitor.configure was NEVER called.
expect(monitor.configure).not.toHaveBeenCalled();
});
test('criticalThresholdPct >= cleanupAggressivePct is rejected with 400', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 60,
criticalThresholdPct: 95,
cleanupAggressivePct: 80,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/criticalThresholdPct.*strictly less than.*cleanupAggressivePct/);
expect(res.body.field).toBe('criticalThresholdPct');
expect(monitor.configure).not.toHaveBeenCalled();
});
test('equal thresholds are rejected (strict <, not <=)', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 80,
criticalThresholdPct: 80,
cleanupAggressivePct: 90,
});
expect(res.status).toBe(400);
expect(monitor.configure).not.toHaveBeenCalled();
});
test('partial update — single field accepted against existing baseline', async () => {
// Defaults: warning=80, critical=90, aggressive=95. Raise warning to 85.
const res = await fetch('POST', '/config', { warningThresholdPct: 85 });
expect(res.status).toBe(200);
expect(res.body.config.warningThresholdPct).toBe(85);
expect(res.body.config.criticalThresholdPct).toBe(90);
expect(res.body.config.cleanupAggressivePct).toBe(95);
});
test('partial update — would violate invariant against baseline, rejected', async () => {
// Defaults: warning=80, critical=90, aggressive=95. Setting warning=95
// would collide with the existing critical=90 (warning >= critical).
const res = await fetch('POST', '/config', { warningThresholdPct: 95 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
expect(monitor.configure).not.toHaveBeenCalled();
});
test('partial update — succeeds after baseline was updated in a prior request', async () => {
// First request: bump warning from 80 → 85 (within current critical=90).
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
expect(res.status).toBe(200);
// Second request: now bump warning from 85 → 89. Still under critical=90.
res = await fetch('POST', '/config', { warningThresholdPct: 89 });
expect(res.status).toBe(200);
expect(monitor.configure).toHaveBeenCalledTimes(2);
});
test('partial update — would violate against the NEW baseline, rejected', async () => {
// Step 1: raise warning to 85.
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
expect(res.status).toBe(200);
// Step 2: try to raise warning to 95 — would collide with critical=90.
res = await fetch('POST', '/config', { warningThresholdPct: 95 });
expect(res.status).toBe(400);
// monitor.configure should have run exactly once (the accepted request).
expect(monitor.configure).toHaveBeenCalledTimes(1);
});
test('out-of-bounds values are clamped to documented ranges', async () => {
// Note: the three values must produce a valid monotonic ordering AFTER
// clamping. Setting warning=20 (→ 50), critical=200 (→ 99), aggressive=70
// would produce critical=99 > aggressive=70 which is rejected by the
// ordering check. Use values that clamp into a valid range.
const res = await fetch('POST', '/config', {
warningThresholdPct: 20, // below warning min 50 → clamped to 50
criticalThresholdPct: 85, // valid
cleanupAggressivePct: 200, // above aggressive max 99 → clamped to 99
});
expect(res.status).toBe(200);
expect(res.body.config).toEqual(expect.objectContaining({
warningThresholdPct: 50,
criticalThresholdPct: 85,
cleanupAggressivePct: 99,
}));
});
test('non-numeric threshold values are silently dropped (legacy behaviour preserved)', async () => {
// Strings are not numbers → unchanged from baseline. Confirms the
// ordering check doesn\'t reject legitimate "I didn\'t change this" requests.
const res = await fetch('POST', '/config', {
warningThresholdPct: '80',
});
expect(res.status).toBe(200);
expect(res.body.config.warningThresholdPct).toBe(80); // baseline unchanged
expect(monitor.configure).toHaveBeenCalledWith({}); // empty updates
});
test('diskBudgetGB and autoCleanup updates still work alongside threshold validation', async () => {
const res = await fetch('POST', '/config', {
diskBudgetGB: 50,
autoCleanup: false,
warningThresholdPct: 81,
});
expect(res.status).toBe(200);
expect(res.body.config.diskBudgetGB).toBe(50);
expect(res.body.config.autoCleanup).toBe(false);
expect(res.body.config.warningThresholdPct).toBe(81);
});
test('rejected request does NOT mutate the live diskConfig', async () => {
const before = { ...monitor._state };
const res = await fetch('POST', '/config', {
warningThresholdPct: 95, // collides with critical=90
});
expect(res.status).toBe(400);
expect(monitor._state).toEqual(before);
});
test('POST /config with no thresholds in body is a no-op against baseline', async () => {
const res = await fetch('POST', '/config', { diskBudgetGB: 25 });
expect(res.status).toBe(200);
expect(res.body.config.diskBudgetGB).toBe(25);
expect(res.body.config.warningThresholdPct).toBe(80); // unchanged
expect(res.body.config.criticalThresholdPct).toBe(90); // unchanged
expect(res.body.config.cleanupAggressivePct).toBe(95); // unchanged
});
});
@@ -1,357 +0,0 @@
/**
* Smoke tests for the enhanced error-logs route (DC-052).
*
* Mirrors `caddy-upstreams.routes.test.js`: build the router with stubbed
* deps, hit it via a tiny express app, assert the response shape and
* the audit-logger interactions.
*
* Fixture: a synthetic error log with two ERR entries and one WARN entry,
* each with a different context, IP, and stack — enough to exercise the
* filter chain (level, context, search, since/until) without pulling the
* real 47k-line error.log off the host.
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const os = require('os');
const ENTRY_SEP = '='.repeat(80);
const FIXTURE_LOG = [
`[2026-08-17T10:00:00.000Z] [ERR] updater: getLocalVersion failed: no candidate package.json found`,
` at SelfUpdater.getLocalVersion (/app/src/docker/self-updater.js:128:9)`,
` request: GET /api/v1/updates/check | ip: 100.85.236.10 | ua: Mozilla/5.0 | id: a1`,
` context: {"triggeredBy":"manual"}`,
ENTRY_SEP,
`[2026-08-17T11:00:00.000Z] [ERR] http: GET /api/v1/templates 503`,
` at Logger.error (/app/src/utils/logging.js:258:49)`,
` request: GET /api/v1/templates | ip: 100.85.236.11 | ua: curl/8.0 | id: a2`,
` context: {"service":"templates"}`,
ENTRY_SEP,
`[2026-08-17T12:00:00.000Z] [WARN] ssl-monitor: cert check failed: TLS connect error for sonarr.sami:443: getaddrinfo ENOTFOUND sonarr.sami`,
` at Logger.warn (/app/src/utils/logging.js:200:10)`,
` request: GET /api/v1/ssl/check | ip: 100.121.150.22 | ua: NodeHealthCheck | id: a3`,
` context: {"service":"sonarr"}`,
ENTRY_SEP,
``,
].join('\n');
function buildFakeAuditLogger() {
return {
clear: jest.fn(async () => {}),
log: jest.fn(async () => {}),
};
}
function writeFixtureLog(tmpDir) {
const logFile = path.join(tmpDir, 'error.log');
fs.writeFileSync(logFile, FIXTURE_LOG);
return logFile;
}
describe('routes/errorlogs (DC-052)', () => {
let tmpDir;
let logFile;
let auditLogger;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc052-errorlogs-'));
logFile = writeFixtureLog(tmpDir);
auditLogger = buildFakeAuditLogger();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function buildRouter() {
const mod = require('../../routes/errorlogs');
return mod({
ERROR_LOG_FILE: logFile,
auditLogger,
asyncHandler: (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
},
});
}
function listen(router) {
const app = express();
app.use(express.json());
app.use(router);
return app.listen(0);
}
test('router exposes the DC-052 endpoints', () => {
const router = buildRouter();
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 /error-logs',
'GET /error-logs/contexts',
'DELETE /error-logs',
]));
});
test('GET /error-logs returns newest-first with totals', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
const body = await res.json();
server.close();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.total).toBe(3);
expect(body.logs).toHaveLength(3);
expect(body.hasMore).toBe(false);
expect(body.filters).toEqual({
level: null, context: null, search: null, since: null, until: null,
});
// Newest first: 12:00 (WARN ssl-monitor) → 11:00 (ERR http) → 10:00 (ERR updater).
expect(body.logs[0].level).toBe('WARN');
expect(body.logs[1].level).toBe('ERR');
expect(body.logs[2].level).toBe('ERR');
expect(body.logs[0].timestamp).toBe('2026-08-17T12:00:00.000Z');
});
test('GET /error-logs filters by level', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=ERR`);
const body = await res.json();
server.close();
expect(body.total).toBe(2);
expect(body.logs.every((e) => e.level === 'ERR')).toBe(true);
});
test('GET /error-logs filters by context (substring)', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`);
const body = await res.json();
server.close();
expect(body.total).toBe(1);
expect(body.logs[0].context).toBe('updater');
});
test('GET /error-logs free-text search hits error / context / detail', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// "sonarr" appears only in the WARN stack; should still match via detail.
let res = await fetch(`http://127.0.0.1:${port}/error-logs?search=sonarr`);
let body = await res.json();
expect(body.total).toBe(1);
expect(body.logs[0].context).toBe('ssl-monitor');
// "503" appears only in the ERR http message; should match via error.
res = await fetch(`http://127.0.0.1:${port}/error-logs?search=503`);
body = await res.json();
expect(body.total).toBe(1);
expect(body.logs[0].context).toBe('http');
server.close();
});
test('GET /error-logs respects since/until as numeric ISO compare', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// Window covers only 11:00Z entry.
const res = await fetch(
`http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('2026-08-17T10:30:00Z')}&until=${encodeURIComponent('2026-08-17T11:30:00Z')}`
);
const body = await res.json();
server.close();
expect(body.total).toBe(1);
expect(body.logs[0].timestamp).toBe('2026-08-17T11:00:00.000Z');
});
test('GET /error-logs rejects invalid since with 400', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?since=not-a-date`);
const body = await res.json();
server.close();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
});
test('GET /error-logs rejects unknown level with 400', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=FATAL`);
const body = await res.json();
server.close();
expect(res.status).toBe(400);
});
test('GET /error-logs paginates and reports hasMore', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res1 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=0`);
const body1 = await res1.json();
expect(body1.logs).toHaveLength(2);
expect(body1.total).toBe(3);
expect(body1.hasMore).toBe(true);
const res2 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=2`);
const body2 = await res2.json();
expect(body2.logs).toHaveLength(1);
expect(body2.hasMore).toBe(false);
server.close();
});
test('GET /error-logs clamps limit to MAX_LIMIT', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?limit=99999`);
const body = await res.json();
server.close();
// 3 entries total so we still get 3, but the route didn't blow up on a
// giant limit; the contract is limit <= 500 and we just clamp.
expect(body.logs.length).toBeLessThanOrEqual(500);
expect(body.total).toBe(3);
});
test('GET /error-logs/contexts returns distinct contexts sorted by count', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`);
const body = await res.json();
server.close();
expect(body.success).toBe(true);
expect(body.contexts).toHaveLength(3);
// updater + http + ssl-monitor — each appears once.
const names = body.contexts.map((c) => c.name).sort();
expect(names).toEqual(['http', 'ssl-monitor', 'updater']);
expect(body.contexts.every((c) => c.count === 1)).toBe(true);
});
test('DELETE /error-logs without confirm is rejected with 400', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`, { method: 'DELETE' });
const body = await res.json();
server.close();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
// File still intact.
expect(fs.readFileSync(logFile, 'utf8')).toBe(FIXTURE_LOG);
});
test('DELETE /error-logs with confirm=CLEAR truncates and audits', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-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.success).toBe(true);
expect(fs.readFileSync(logFile, 'utf8')).toBe('');
expect(auditLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'error-log.clear',
outcome: 'success',
}));
});
test('GET /error-logs returns empty when log file missing', async () => {
fs.unlinkSync(logFile);
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
const body = await res.json();
server.close();
expect(body.success).toBe(true);
expect(body.logs).toEqual([]);
expect(body.total).toBe(0);
});
test('GET /error-logs preserves stack frames in detail field', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`);
const body = await res.json();
server.close();
expect(body.logs[0].detail).toContain('self-updater.js:128');
expect(body.logs[0].detail).toContain('context:');
});
test('GET /error-logs handles malformed entry as raw fallback', async () => {
// The parser splits the log on ENTRY_SEP (80 equal signs). A junk block
// that has no timestamp header should still surface as a raw entry so
// the operator doesn't lose forensic context. Place the malformed
// block AFTER the separator so it ends up in its own split segment.
fs.writeFileSync(logFile, [
`[2026-08-17T13:00:00.000Z] [ERR] junk: well-formed entry`,
ENTRY_SEP,
`this is a malformed block with no timestamp header`,
`and no level bracket at all`,
ENTRY_SEP,
``,
].join('\n'));
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
const body = await res.json();
server.close();
expect(body.total).toBe(2);
const raw = body.logs.find((e) => e.level === null);
expect(raw).toBeDefined();
expect(raw.error).toContain('malformed block');
expect(raw.raw).toContain('malformed block');
});
test('GET /error-logs/contexts returns empty array when file missing', async () => {
fs.unlinkSync(logFile);
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`);
const body = await res.json();
server.close();
expect(body.success).toBe(true);
expect(body.contexts).toEqual([]);
});
test('GET /error-logs?search matches IP field', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// 100.85.236.11 is only on the /api/v1/templates entry.
const res = await fetch(`http://127.0.0.1:${port}/error-logs?search=100.85.236.11`);
const body = await res.json();
server.close();
expect(body.total).toBe(1);
expect(body.logs[0].request.ip).toBe('100.85.236.11');
});
test('GET /error-logs accepts huge since/until without error', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// Far-future since — no entries match, but the route doesn't 500.
const res = await fetch(
`http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('9999-12-31T00:00:00Z')}`
);
const body = await res.json();
server.close();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.total).toBe(0);
expect(body.logs).toEqual([]);
});
test('GET /error-logs combined filters compose correctly', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// level=WARN AND context=http: no entry matches (WARN is ssl-monitor).
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=WARN&context=http`);
const body = await res.json();
server.close();
expect(body.total).toBe(0);
expect(body.logs).toEqual([]);
expect(body.filters).toEqual({
level: 'WARN', context: 'http', search: null,
since: null, until: null,
});
});
});
@@ -1,196 +0,0 @@
/**
* DC-055: Host journald route smoke tests.
*
* Mounts the routes/logs.js journald endpoints into a tiny express app
* with a mocked journald reader. The mock mirrors the real module's
* validation pipeline (assertUnitAllowed, parseTail, parseTimestamp) so
* bad inputs still throw ValidationError -> 400 at the route boundary,
* but the actual journalctl spawn is short-circuited.
*/
const express = require('express');
const request = require('supertest');
const path = require('path');
const realJournaldPath = require.resolve('../../src/monitoring/journald-reader.js');
// Pull the real module's validators so the mock's readEntries can
// reproduce the same 400-on-bad-input behaviour as production.
const realReader = jest.requireActual(realJournaldPath);
// Mocked journald reader. Variable name MUST start with "mock" so
// jest.mock hoisting doesn't reject the factory closure.
const mockJournald = {
ALLOWED_UNITS: realReader.ALLOWED_UNITS,
MAX_TAIL_LINES: realReader.MAX_TAIL_LINES,
MAX_OUTPUT_BUFFER: realReader.MAX_OUTPUT_BUFFER,
isAvailable: jest.fn().mockResolvedValue(true),
// Validation pipeline runs through the real assert/parse functions so
// bad unit/tail/since/until still surface as ValidationError. The
// journalctl spawn itself is short-circuited — return canned entries.
readEntries: jest.fn(async (opts) => {
const unit = realReader.assertUnitAllowed(opts.unit);
realReader.parseTail(opts.tail); // throws on bad tail
realReader.parseTimestamp(opts.since, 'since');
realReader.parseTimestamp(opts.until, 'until');
return [
{ timestamp: 'Aug 18 00:42:46', hostname: 'host', unit, text: 'mock-line-1' },
];
}),
// Default stream mock: invokes onData with one synthetic entry then
// returns a no-op handle. Tests override per-case.
streamEntries: jest.fn((opts, hooks = {}) => {
if (hooks.onData) {
hooks.onData({ timestamp: 'Aug 18 00:42:46', unit: opts.unit, text: 'stream-line-1' });
}
return { kill: jest.fn(), child: {} };
}),
listUnits: jest.fn(async () => [
{ unit: 'caddy', hasEntries: true },
{ unit: 'docker', hasEntries: true },
]),
assertUnitAllowed: realReader.assertUnitAllowed,
parseTail: realReader.parseTail,
parseTimestamp: realReader.parseTimestamp,
parseShortLine: realReader.parseShortLine,
buildArgv: realReader.buildArgv,
};
jest.mock('../../src/monitoring/journald-reader.js', () => mockJournald);
// Force journaldAvailable = true in routes/logs.js. The route checks
// /var/log/journal + /usr/bin/journalctl at module-load time, so we stub
// fs.existsSync to lie about those paths.
const realFs = require('fs');
const realExists = realFs.existsSync;
realFs.existsSync = function(p) {
if (p === '/var/log/journal' || p === '/usr/bin/journalctl') return true;
return realExists.apply(this, arguments);
};
const logsRoutes = require('../../routes/logs.js');
function buildApp() {
const app = express();
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const ok = (res, data) => res.json({ success: true, ...data });
const errorHandler = (err, req, res, next) => {
const status = err.statusCode || (err.name === 'ValidationError' ? 400 : 500);
res.status(status).json({ success: false, error: err.message });
};
app.use('/api/v1', logsRoutes({ asyncHandler, ok }));
app.use(errorHandler);
return app;
}
describe('routes /logs/journal', () => {
let app;
beforeEach(async () => {
mockJournald.readEntries.mockClear();
mockJournald.streamEntries.mockClear();
mockJournald.listUnits.mockClear();
app = buildApp();
// Let any keep-alive socket from the prior test close before we
// bind a new express app.
await new Promise(r => setTimeout(r, 10));
});
describe('GET /logs/journal/units', () => {
test('returns unit list when journald is mounted', async () => {
const res = await request(app).get('/api/v1/logs/journal/units');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.available).toBe(true);
expect(res.body.units.length).toBeGreaterThanOrEqual(1);
});
});
describe('GET /logs/journal', () => {
test('returns entries for caddy', async () => {
const res = await request(app)
.get('/api/v1/logs/journal')
.query({ unit: 'caddy', tail: 50 });
expect(res.status).toBe(200);
expect(res.body.entries.length).toBeGreaterThanOrEqual(1);
expect(res.body.entries[0].unit).toBe('caddy');
expect(mockJournald.readEntries).toHaveBeenCalled();
const call = mockJournald.readEntries.mock.calls[0][0];
expect(call.unit).toBe('caddy');
expect(call.tail).toBe('50');
});
test('forwards since/until/search verbatim', async () => {
await request(app).get('/api/v1/logs/journal').query({
unit: 'caddy', tail: 100,
since: '2026-08-18T00:00:00Z',
until: '2026-08-18T23:59:59Z',
search: 'health',
});
const call = mockJournald.readEntries.mock.calls[0][0];
expect(call.since).toBe('2026-08-18T00:00:00Z');
expect(call.until).toBe('2026-08-18T23:59:59Z');
expect(call.search).toBe('health');
});
test('returns 400 when unit not in allow-list', async () => {
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'nginx' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not in allow-list/);
// The reader is called and rejects; the route layer maps the
// ValidationError to 400 without doing any spawn.
expect(mockJournald.readEntries).toHaveBeenCalled();
});
test('returns 400 when unit contains shell metacharacters', async () => {
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy; rm -rf /' });
expect(res.status).toBe(400);
expect(mockJournald.readEntries).toHaveBeenCalled();
});
test('returns 400 when tail is invalid', async () => {
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy', tail: 'oops' });
expect(res.status).toBe(400);
});
test('returns 500 when reader throws non-validation error', async () => {
mockJournald.readEntries.mockRejectedValueOnce(new Error('journalctl exited 1: bad dir'));
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy' });
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/journalctl exited 1/);
});
});
describe('GET /logs/journal/stream', () => {
test('opens SSE with correct content-type for a valid unit', async () => {
// Stub the mock to immediately call onError so the route ends
// the response and supertest can collect it. Production SSE
// streams stay open until the client disconnects — covered by
// the journald-reader.streamEntries unit tests.
mockJournald.streamEntries.mockImplementationOnce((opts, hooks) => {
setTimeout(() => hooks.onError && hooks.onError(new Error('synthetic-EOF')), 5);
return { kill: jest.fn(), child: {} };
});
const res = await request(app)
.get('/api/v1/logs/journal/stream')
.query({ unit: 'caddy' })
.timeout(2000);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
});
test('400 when unit not in allow-list', async () => {
// The route pre-validates with journald.assertUnitAllowed BEFORE
// opening SSE — invalid unit returns a 400 JSON response without
// touching the stream.
const res = await request(app)
.get('/api/v1/logs/journal/stream')
.query({ unit: 'nginx' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not in allow-list/);
// streamEntries must NOT have been called for a bad unit.
expect(mockJournald.streamEntries).not.toHaveBeenCalled();
});
});
});
@@ -1,215 +0,0 @@
/**
* Caddy admin API CSRF Origin-header tests — DC-051
*
* Verifies:
* - _httpFetch (fetchT's :2019 raw http branch) injects `Origin: http://<host>:<port>`
* for any Caddy admin URL, satisfying Caddy's `enforce_origin` CSRF check
* that activates on non-loopback admin binds (e.g. `admin 0.0.0.0:2019`).
* - Caller-provided Origin via opts.headers WINS over the auto-injected
* default (so future proxies / tests can override).
* - fetchT routes :2019 URLs through _httpFetch (raw http.request) and
* leaves HTTPS URLs on Node's undici fetch (for self-signed cert support).
* - The /config/apps/http/servers/srv0/listen health probe that the readiness
* handler emits against http://localhost:2019 includes the Origin header.
*
* Regression for the live 403 spam observed on DNS2 (Caddy log:
* `{"error":"client is not allowed to access from origin ''","status_code":403}`
* from User-Agent:node + Sec-Fetch-Mode:cors at remote_port 5xxxx, repeated
* every ~10s while the readiness workflow probes Caddy admin). The fix is
* the Origin header injection here + the `origins` directive in the
* Caddyfile's admin block on DNS2 — both are required for Caddy's CSRF
* check to accept same-origin admin calls.
*/
// Capture the http.request call shape without spinning up a real server.
// We do this by reading the http.js source and exporting a probe function
// that the test calls directly — this avoids brittle mock plumbing while
// still proving the Origin header is constructed correctly.
//
// Strategy: the test imports a small wrapper that exposes the request
// construction step from _httpFetch in isolation, then asserts on the
// returned options.
const path = require('path');
const fs = require('fs');
// Strip JS comments so docblock prose doesn't false-positive on regex
// patterns that look for code (e.g. `origins`, `enforce_origin`).
// IMPORTANT: do not strip `//` inside template literals — those are
// URL/comment sequences like `http://${parsed.hostname}:${parsed.port}`.
// We do this in two passes: (1) protect template-literal contents by
// replacing them with placeholders, (2) strip comments, (3) restore
// the placeholders.
function stripComments(src) {
// Pass 1: replace template literals (backtick-delimited) with sentinels.
const templates = [];
let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
const idx = templates.length;
templates.push(match);
return `\u0000TPL${idx}\u0000`;
});
// Pass 2: strip block + line comments from the now-comment-safe string.
protectedSrc = protectedSrc
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/(^|[^:])\/\/.*$/gm, '$1'); // line comments, leaving URLs alone
// Pass 3: restore template literals.
return protectedSrc.replace(/\u0000TPL(\d+)\u0000/g, (_, idx) => templates[+idx]);
}
const { fetchT } = require('../src/utils/http');
describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', () => {
test('http.js _httpFetch computes Origin from parsed URL host+port', () => {
// Read the source file and verify the Origin line is constructed from
// the parsed URL's hostname+port, matching what the readiness probe needs.
const code = stripComments(fs.readFileSync(
path.join(__dirname, '../src/utils/http.js'),
'utf8'
));
// 1. The default origin is built from the parsed URL
expect(code).toMatch(/const defaultOrigin\s*=\s*`\$\{parsed\.protocol\}\/\/\$\{parsed\.hostname\}:\$\{parsed\.port\s*\|\|\s*2019\}`/);
// 2. The Origin header is set, with caller opts.headers spread after
// (so caller wins on duplicate keys)
expect(code).toMatch(/headers:\s*{\s*Origin:\s*defaultOrigin,\s*\.\.\.opts\.headers,/);
// 3. The router still routes :2019 to _httpFetch (raw http.request)
expect(code).toMatch(/if\s*\(url\.includes\(':2019'\)\)/);
// 4. Comments explain the CSRF rationale (regression-proofing).
// We check the RAW (with comments) source so this catches accidental
// removal of the rationale docblock too.
const raw = fs.readFileSync(
path.join(__dirname, '../src/utils/http.js'),
'utf8'
);
expect(raw).toMatch(/enforce_origin/);
expect(raw).toMatch(/origins/);
});
test('all :2019 call sites use fetchT (not raw fetch)', () => {
// Every Caddy admin API call in the API code should go through fetchT,
// not bare fetch — fetchT routes :2019 through _httpFetch which now
// injects Origin. A new call site using bare fetch would skip the
// CSRF fix and re-introduce the 403 loop.
const apiRoot = path.join(__dirname, '..');
const offenders = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
const p = path.join(dir, entry.name);
if (entry.isDirectory()) walk(p);
else if (entry.name.endsWith('.js')) {
const text = stripComments(fs.readFileSync(p, 'utf8'));
// Find every `fetch(` call and check whether the SAME call contains
// a :2019 URL — if so, it should be `fetchT(` instead.
const matches = text.match(/await\s+fetch\(([^)]*)\)/g) || [];
for (const m of matches) {
if (/:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(m)) {
offenders.push(`${p}: ${m.slice(0, 100)}`);
break;
}
}
}
}
}
walk(apiRoot);
expect(offenders).toEqual([]);
});
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
const raw = fs.readFileSync(
path.join(__dirname, '../src/app.js'),
'utf8'
);
// The probe URL is the one that was 403-looping every 10s in prod.
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
// line / call site — the call must be `fetchT(...)`, not `await fetch(...)`.
// (We look backward because the URL sits inside the call's argument list,
// so the call site comes before the URL token.)
const idx = raw.indexOf('srv0/listen');
const around = raw.substr(Math.max(0, idx - 400), 800);
expect(around).toMatch(/fetchT\(/);
expect(around).not.toMatch(/await fetch\(/);
});
test('end-to-end: fetchT sends Origin header to a real HTTP server on :2019', async () => {
// Spin up a minimal HTTP server on a port that LOOKS like :2019 from
// fetchT's router perspective. We use port :20190 (contains ':2019'
// substring so url.includes(':2019') is true → routes through _httpFetch)
// to avoid clashing with any local Caddy on the canonical :2019.
const http = require('http');
let capturedHeaders = null;
const server = http.createServer((req, res) => {
capturedHeaders = req.headers;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('["::"]');
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(20190, '127.0.0.1', resolve);
});
try {
// fetchT routes this URL through _httpFetch because it includes
// ':2019' as a substring. _httpFetch computes Origin from the
// parsed URL — parsed.port is '20190' here, so Origin is
// http://127.0.0.1:20190.
const result = await fetchT(
'http://127.0.0.1:20190/config/apps/http/servers/srv0/listen',
{},
5000
);
expect(result.status).toBe(200);
expect(capturedHeaders.origin).toBe('http://127.0.0.1:20190');
// raw http doesn't add User-Agent by default
expect(capturedHeaders['user-agent']).toBeUndefined();
// critical: no Sec-Fetch-Mode: cors (that's what triggers Caddy's CSRF)
expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
} finally {
await new Promise((r) => server.close(r));
}
});
test('Caddyfile template documents the origins directive for non-loopback admin bind', () => {
// The HIGH-severity fix from GLM review: the live /etc/caddy/Caddyfile
// is operator-managed (via caddy-apply, NOT in this repo), so this
// test guards the only Caddyfile that IS in the repo — the installer
// template — so any future operator using `admin 0.0.0.0:2019` (like
// DNS2 does for the docker bridge to reach it) sees the same shape
// and isn't surprised by the 403 loop. If a future change adopts
// non-loopback admin in the template, this test demands the `origins`
// directive alongside it.
const tmplPath = path.join(__dirname, '../dashcaddy-installer/templates/Caddyfile.template');
const exists = fs.existsSync(tmplPath);
if (!exists) {
// Template absent (maybe removed in a refactor) — skip with explicit note
console.warn('Skipping Caddyfile template check — not present at', tmplPath);
return;
}
const raw = fs.readFileSync(tmplPath, 'utf8');
// Strip comments to look at the actual config shape.
const code = stripComments(raw);
const adminBlock = code.match(/admin\s+([^{\s]+)(?:\s+\{([^}]*)\})?/);
if (!adminBlock) {
// No admin block configured at all — operator default; nothing to check.
return;
}
const listen = adminBlock[1];
const isLoopback = listen === '127.0.0.1:2019' || listen === 'localhost:2019' || listen === '::1:2019';
const inner = adminBlock[2] || '';
if (!isLoopback) {
// Non-loopback bind — the `origins` directive is REQUIRED to prevent
// the 403 loop we just fixed. This assertion will fail if someone
// changes the template to non-loopback without adding origins.
expect(inner).toMatch(/origins\s/);
} else {
// Loopback bind — Caddy allows loopback origins implicitly, so the
// `origins` directive is unnecessary. We just verify the template
// shape is consistent (admin bind + optional inner block).
expect(listen).toMatch(/:2019/);
}
});
});
@@ -1,209 +0,0 @@
/**
* Tests for AggregateError / .cause-chain diagnostic surfacing in
* src/utils/logging.js writeErrorLog().
*
* Bug fixed: writeErrorLog previously emitted `error.message` alone.
* AggregateError's `.message` is "" by spec, so a real aggregate (e.g.
* `await Promise.any([fetch(...), fetch(...)])` or a multi-A DNS lookup
* that times out) ended up in error.log as a single empty line:
*
* [2026-08-18T06:49:03.345Z] [ERR] update:
* context: {"imageName":"ipfs/kubo:latest"}
*
* Operators couldn't tell why the check failed. This file asserts the
* fixed behavior:
*
* - AggregateError → emits a diagnostic block listing each sub-error's
* .code/.message.
* - Regular Error → no spurious diagnostic block.
* - Plain Error with `.code` (e.g. EPIPE) → head now shows
* `Error [EPIPE]: write EPIPE` (regression: `code` used to be dropped).
* - Error wrapping another Error via `.cause` → lists the cause.
* - AggregateError with mixed sub-errors (some Aggregate, some plain) →
* recurses correctly without losing any message.
* - Empty error.message is replaced with the error name so a bare
* AggregateError still renders something readable.
*
* log.error signature on this codebase: error(ctx, err, req?, extra?)
* where extra is the JSON tail (and req is the Express req if any).
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
// Important: set LOG_DIR / ERROR_LOG_FILE BEFORE requiring logging.js so
// the per-test temp file is used as the log target.
const tmpDir = fs.realpathSync ? require('fs').realpathSync(os.tmpdir()) : os.tmpdir();
const TMP_LOG = path.join(tmpDir, `dashcaddy-error-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
process.env.LOG_DIR = tmpDir;
process.env.ERROR_LOG_FILE = TMP_LOG;
process.env.AUDIT_LOG_FILE = path.join(tmpDir, 'unused-audit.json');
const { log } = require('../src/utils/logging');
async function readTail(n = 1) {
const raw = await fs.readFile(TMP_LOG, 'utf8').catch(() => '');
const sep = '\u2500'.repeat(72);
const entries = raw.split(sep).map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean);
return entries.slice(-n);
}
describe('writeErrorLog() — AggregateError + .cause diagnostics', () => {
afterAll(async () => {
try { await fs.unlink(TMP_LOG); } catch (_) {}
});
beforeEach(async () => {
try { await fs.unlink(TMP_LOG); } catch (_) {}
});
test('plain Error: head contains name + message + stack', async () => {
await log.error('plain', new Error('boom'), null, { requestId: 'r1' });
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] plain: Error: boom/);
expect(entry).not.toMatch(/diagnostic:/); // no spurious diagnostic block
expect(entry).toMatch(/\n {4}at /); // stack preserved (lowercase `at` from V8)
expect(entry).toMatch(/context: \{.*requestId.*"r1".*\}/);
});
test('plain Error with .code renders the code in the head (regression fix)', async () => {
const e = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
await log.error('stream', e);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] stream: Error \[EPIPE\]: write EPIPE/);
expect(entry).not.toMatch(/diagnostic:/);
});
test('custom Error subclass name is preserved in the head', async () => {
class WidgetError extends Error {
constructor(msg) { super(msg); this.name = 'WidgetError'; }
}
await log.error('sub', new WidgetError('blew up'));
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] sub: WidgetError: blew up/);
});
test('empty error.message falls back to the bare error.name (defensive)', async () => {
const empty = new Error('');
await log.error('empty', empty);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] empty: Error$/m);
});
test('AggregateError with sub-errors emits a diagnostic block listing each cause', async () => {
// Realistic shape: registry-1.docker.io multi-A lookup timeout returning
// an AggregateError of ECONNREFUSED / Timeout / EAI_AGAIN sub-errors.
const agg = new AggregateError(
[
Object.assign(new Error('connect ECONNREFUSED 157.240.20.50:443'), { code: 'ECONNREFUSED' }),
Object.assign(new Error('connect ETIMEDOUT 157.240.21.50:443'), { code: 'ETIMEDOUT' }),
Object.assign(new Error('getaddrinfo EAI_AGAIN registry-1.docker.io'), { code: 'EAI_AGAIN' }),
],
''
);
await log.error('update', agg, null, { imageName: 'ipfs/kubo:latest' });
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] update: AggregateError/);
expect(entry).toMatch(/diagnostic:/);
expect(entry).toMatch(/cause #1:/);
expect(entry).toMatch(/cause #2:/);
expect(entry).toMatch(/cause #3:/);
expect(entry).toMatch(/Error \[ECONNREFUSED\]: connect ECONNREFUSED 157\.240\.20\.50:443/);
expect(entry).toMatch(/Error \[ETIMEDOUT\]: connect ETIMEDOUT 157\.240\.21\.50:443/);
expect(entry).toMatch(/Error \[EAI_AGAIN\]: getaddrinfo EAI_AGAIN registry-1\.docker\.io/);
expect(entry).toMatch(/context: \{.*imageName.*"ipfs\/kubo:latest".*\}/);
// No double header for AggregateError (we suppress the empty head line).
expect(entry).not.toMatch(/diagnostic: AggregateError/);
});
test('Error with .cause emits a nested diagnostic block', async () => {
const inner = new Error('TLS handshake failed');
const outer = new Error('fetch failed', { cause: inner });
await log.error('net', outer);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] net: Error: fetch failed/);
expect(entry).toMatch(/cause:/);
expect(entry).toMatch(/Error: TLS handshake failed/);
});
test('nested AggregateError (sub-error is itself an Aggregate) recurses', async () => {
const inner = new AggregateError([new Error('inner-A'), new Error('inner-B')], '');
const outer = new AggregateError([new Error('outer-X'), inner], '');
await log.error('rec', outer);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] rec: AggregateError/);
expect(entry).toMatch(/cause #1:[\s\S]*Error: outer-X/);
// inner is itself an Aggregate, so its child errors surface as "cause #N":
expect(entry).toMatch(/inner-A/);
expect(entry).toMatch(/inner-B/);
});
test('separator is appended after each entry (file-format invariant)', async () => {
await log.error('sep', new Error('one'));
await log.error('sep', new Error('two'));
const raw = await fs.readFile(TMP_LOG, 'utf8');
const sep = '\u2500'.repeat(72);
// Count separator occurrences without reserved regex chars tripping us up.
const re = new RegExp(sep.split('').map(c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')).join(''), 'g');
const occurrences = (raw.match(re) || []).length;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
test('req field is still emitted when the calling site passes a request', async () => {
const req = { method: 'POST', path: '/api/v1/widgets', ip: '10.0.0.5', get: () => 'curl/8', id: 'r-42' };
await log.error('withreq', new Error('widget blew up'), req);
const [entry] = await readTail();
expect(entry).toMatch(/request: POST \/api\/v1\/widgets \| ip: 10\.0\.0\.5 \| ua: curl\/8 \| id: r-42/);
});
test('extra context JSON is still emitted after stack (regression)', async () => {
await log.error('ctx', new Error('payload'), null, { operation: 'rotate', tenantId: 7 });
const [entry] = await readTail();
expect(entry).toMatch(/context: \{"operation":"rotate","tenantId":7\}/);
});
// Polish-grade hardening (per GLM round-1 B+ findings): cycle guard + depth cap.
test('circular .cause references do not infinite-loop (cycle guard)', async () => {
const a = new Error('top');
const b = new Error('middle');
const c = new Error('bottom');
// c.cause = b would be normal; force a CYCLE by linking back to a.
b.cause = a;
a.cause = c;
c.cause = a; // cycle: a <-> a
await expect(log.error('cycle', a, null)).resolves.not.toThrow();
const [entry] = await readTail();
expect(entry).toMatch(/top/);
expect(entry).toMatch(/cycle: same Error instance seen earlier/);
});
test('excessively deep .cause chains are truncated, not crashed (depth cap)', async () => {
// Build a chain 50 deep ending in 'level-50' at the deepest; each layer
// wraps the previous via .cause. log.error is called with the deepest
// (outer) Error.
let cur = new Error('level-1');
for (let i = 2; i <= 50; i++) {
const parent = new Error(`level-${i}`);
parent.cause = cur;
cur = parent;
}
await expect(log.error('deep', cur)).resolves.not.toThrow();
const [entry] = await readTail();
expect(entry).toMatch(/chain truncated at depth 16/);
expect(entry).toMatch(/level-50/); // the deepest/head shown in headline
expect(entry).not.toMatch(/level-1/); // the leaf is too deep to render
});
test('circular `.errors` array (sub-error is itself in the parent) is bounded', async () => {
const sub = new Error('shared sub-error');
const agg = new AggregateError([sub, new Error('other')], '');
// pathological: sub-Aggregate references the parent
sub.errors = [agg];
await expect(log.error('aggcycle', agg)).resolves.not.toThrow();
const [entry] = await readTail();
expect(entry).toMatch(/shared sub-error/);
expect(entry).toMatch(/cycle: same Error instance seen earlier/);
});
});
+32 -7
View File
@@ -22,13 +22,6 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
const router = express.Router();
// ==================== SCHEDULE ENDPOINTS (PREMIUM) ====================
// NOTE: POST /backups/schedule has a single canonical registration below
// (the appId-keyed handler at the top of this section). Earlier versions
// registered a duplicate "name"-keyed handler later in the file — Express
// only matches the first registered handler per METHOD+PATH, so the
// duplicate was unreachable dead code. Do not re-add it; if you need a
// different schema, change the canonical Joi schema in
// src/utilities/validate.js (backupScheduleCreate) instead.
// Apply premium gating to schedule-related routes
const premiumGating = licenseManager.requirePremium('auto-backup');
@@ -518,6 +511,38 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
success(res, storageInfo);
}, 'backups-storage-info'));
// Schedule a backup
// LEGACY: this is a duplicate registration of POST /backups/schedule (see also line 60,
// which uses the appId-keyed schema and is the route the frontend actually calls).
// Express only matches the first registered handler per METHOD+PATH, so this handler
// is unreachable. It is preserved for now to avoid removing a route any unknown
// integration might still POST to. TODO: audit + remove in a dedicated cleanup PR.
router.post('/backups/schedule', asyncHandler(async (req, res) => {
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
if (!name || !schedule) {
return res.status(400).json({ error: 'name and schedule are required' });
}
const config = backupManager.getConfig();
// Store maxStorageBytes in the backup config (converted to bytes)
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
? maxStorageBytes
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
config.backups[name] = {
...backupConfig,
enabled: true,
schedule,
maxStorageBytes: maxBytes,
destinations: backupConfig.destinations || [{ type: 'local' }]
};
backupManager.updateConfig(config);
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
}, 'backups-schedule-legacy'));
// Restore from backup
router.post('/backups/restore/:backupId', validateBody(schemas.backupRestore), asyncHandler(async (req, res) => {
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
+3 -81
View File
@@ -1,6 +1,5 @@
const express = require('express');
const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../src/utilities/errors');
/**
* Disk space management routes
@@ -11,76 +10,6 @@ const { ValidationError } = require('../src/utilities/errors');
* POST /disk/config — update disk budget settings
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
*/
// DC-059: monotonic-ordering invariant for the three threshold percentages.
// DiskSpaceMonitor._getBudgetStatus() walks them in order
// (cleanupAggressivePct → criticalThresholdPct → warningThresholdPct) and
// returns at the FIRST threshold the usage crosses. If a caller writes
// them out of order (e.g. warningThresholdPct=95, criticalThresholdPct=60),
// the higher-priority branches become unreachable and the monitor silently
// misclassifies budget state. Validate against the *effective* config
// (current value + incoming update for each field) so partial updates can
// be applied one field at a time without violating the invariant.
//
// Clamp values to the same ranges the previous inline Math.min/Math.max
// chains enforced (warning 50..99, critical 60..99, aggressive 70..99)
// so we don't loosen the original bounds while adding the new check.
const THRESHOLD_BOUNDS = Object.freeze({
warning: { min: 50, max: 99 },
critical: { min: 60, max: 99 },
aggressive: { min: 70, max: 99 },
});
function clampThreshold(name, value) {
const { min, max } = THRESHOLD_BOUNDS[name];
return Math.min(Math.max(value, min), max);
}
/**
* Apply a candidate update to a baseline config, then verify the three
* threshold percentages still satisfy
* warningThresholdPct < criticalThresholdPct < cleanupAggressivePct.
* The POST /config endpoint accepts partial updates (single field at a
* time), so we merge into the live diskSpaceMonitor config first, then test
* the merged value. Returns the merged candidate on success; throws
* ValidationError if the ordering invariant would be violated.
*
* @param {Object} baseline - current effective config from diskSpaceMonitor
* @param {Object} candidate - the partial update being applied this request
* @returns {Object} merged candidate with thresholds clamped to bounds
*/
function mergeAndCheckOrdering(baseline, candidate) {
const next = { ...baseline };
if (typeof candidate.warningThresholdPct === 'number') {
next.warningThresholdPct = clampThreshold('warning', candidate.warningThresholdPct);
}
if (typeof candidate.criticalThresholdPct === 'number') {
next.criticalThresholdPct = clampThreshold('critical', candidate.criticalThresholdPct);
}
if (typeof candidate.cleanupAggressivePct === 'number') {
next.cleanupAggressivePct = clampThreshold('aggressive', candidate.cleanupAggressivePct);
}
if (!(next.warningThresholdPct < next.criticalThresholdPct)) {
throw new ValidationError(
`warningThresholdPct (${next.warningThresholdPct}) must be strictly less than criticalThresholdPct (${next.criticalThresholdPct})`,
'warningThresholdPct'
);
}
if (!(next.criticalThresholdPct < next.cleanupAggressivePct)) {
throw new ValidationError(
`criticalThresholdPct (${next.criticalThresholdPct}) must be strictly less than cleanupAggressivePct (${next.cleanupAggressivePct})`,
'criticalThresholdPct'
);
}
// Return only the fields the caller asked to change (preserves partial-
// update semantics; diskSpaceMonitor.configure does its own merge).
const out = {};
if (typeof candidate.warningThresholdPct === 'number') out.warningThresholdPct = next.warningThresholdPct;
if (typeof candidate.criticalThresholdPct === 'number') out.criticalThresholdPct = next.criticalThresholdPct;
if (typeof candidate.cleanupAggressivePct === 'number') out.cleanupAggressivePct = next.cleanupAggressivePct;
return out;
}
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
const router = express.Router();
@@ -107,16 +36,9 @@ module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
const updates = {};
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
// DC-059: threshold percentages must satisfy a strict monotonic order
// (warning < critical < aggressive) so _getBudgetStatus() reaches the
// correct branch. mergeAndCheckOrdering() validates against the live
// baseline, so partial updates that violate the invariant are rejected
// BEFORE we mutate diskSpaceMonitor.diskConfig.
const thresholdUpdates = mergeAndCheckOrdering(
diskSpaceMonitor.getConfig(),
{ warningThresholdPct, criticalThresholdPct, cleanupAggressivePct }
);
Object.assign(updates, thresholdUpdates);
if (typeof warningThresholdPct === 'number') updates.warningThresholdPct = Math.min(Math.max(warningThresholdPct, 50), 99);
if (typeof criticalThresholdPct === 'number') updates.criticalThresholdPct = Math.min(Math.max(criticalThresholdPct, 60), 99);
if (typeof cleanupAggressivePct === 'number') updates.cleanupAggressivePct = Math.min(Math.max(cleanupAggressivePct, 70), 99);
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
if (typeof enabled === 'boolean') updates.enabled = enabled;
+124 -216
View File
@@ -1,29 +1,83 @@
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const { exists } = require('../src/utilities/fs-helpers');
const { success, error: errorResponse } = require('../src/utils/responses');
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
*
* DC-052: Enhanced the legacy `GET /error-logs` tail handler with:
* - Server-side filtering by level (ERR / WARN), context (substring),
* free-text search across error+message+stack, and time window (since/until).
* - Real pagination via limit/offset (the legacy handler returned only the
* last 50 entries, which made it impossible to inspect older entries
* once the file grew past 5MB — the logging module rotates at 5MB).
* - Distinct-context endpoint for populating the frontend filter dropdown.
* - Confirm=CLEAR gating on DELETE so an accidental click can't wipe
* forensic context (matches the audit-log DC-050 hardening).
*
* The audit-log routes that previously lived here moved to
* `routes/audit-log.js` (DC-050). We keep thin proxy handlers so any
* client still talking to /api/v1/audit-logs gets the new behaviour
* without an extra hop — the actual route module is preferred when
* mounted, but this defensive duplicate means a partial deploy
* (apiRouter only loads this file) still serves correct answers.
*
* @param {Object} deps - Explicit dependencies
* @param {string} deps.ERROR_LOG_FILE - Path to error log file
* @param {Object} deps.auditLogger - Audit logger instance
@@ -33,216 +87,70 @@ const { success, error: errorResponse } = require('../src/utils/responses');
module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
const router = express.Router();
// ── DC-052: Robust entry parser ────────────────────────────────────────
// The error log format produced by src/utils/logging.js is:
// [ISO_TIMESTAMP] [LEVEL] ctx: message
// <stack frames...>
// request: ... | ip: ... | ua: ... | id: ...
// context: {...}
// ──── (80 equal-signs) ────
// Anything between two 80-equal lines is one entry. The legacy parser
// assumed `[ts] ctx: msg` with no LEVEL field; we now extract LEVEL and
// collapse multi-line context/request blocks into structured fields so the
// frontend can filter/search on them.
const ENTRY_SEP = '='.repeat(80);
const HEADER_RE = /^\[([^\]]+)\] \[([^\]]+)\] ([^:]+): (.*)$/;
const REQUEST_RE = /request:\s*(.*?)\s*\|\s*ip:\s*(\S+)\s*\|\s*ua:\s*(.*?)\s*\|\s*id:\s*(\S+)/;
const CONTEXT_RE = /context:\s*(\{[^\n]*\})\s*$/m;
function parseEntries(logContent) {
const raw = logContent.split(ENTRY_SEP);
const entries = [];
for (const block of raw) {
const trimmed = block.trim();
if (!trimmed) continue;
const lines = trimmed.split('\n');
const headerLine = lines[0];
const m = headerLine.match(HEADER_RE);
if (!m) {
// Unknown shape — keep it as a "raw" entry so nothing gets silently
// dropped from the operator's view.
entries.push({
timestamp: null,
level: null,
context: null,
error: trimmed,
request: null,
contextJson: null,
raw: trimmed,
_rawTimestamp: 0,
});
continue;
}
const [, timestamp, level, context, message] = m;
const bodyLines = lines.slice(1);
const bodyText = bodyLines.join('\n');
const reqMatch = bodyText.match(REQUEST_RE);
const ctxMatch = bodyText.match(CONTEXT_RE);
let contextJson = null;
if (ctxMatch) {
try { contextJson = JSON.parse(ctxMatch[1]); } catch { /* leave as null */ }
}
entries.push({
timestamp,
level,
context,
error: message,
request: reqMatch ? {
method_path: reqMatch[1] || '',
ip: reqMatch[2] || '',
ua: reqMatch[3] || '',
id: reqMatch[4] || '',
} : null,
contextJson,
// The full multi-line block (header + stack + request + context) for
// the "click to expand" detail view in the UI.
detail: trimmed,
_rawTimestamp: timestamp ? Date.parse(timestamp) || 0 : 0,
});
}
return entries;
}
// Validate ISO timestamp strings (since/until) — accept anything
// Date.parse() understands so we don't reject a bare "2026-08-17".
function parseTimestamp(raw, fieldName) {
if (!raw) return null;
const t = Date.parse(raw);
if (Number.isNaN(t)) {
throw new Error(`Invalid ${fieldName} timestamp: ${raw}`);
}
return t;
}
// Cap limit so a misconfigured client can't ask for the entire log
// (which could be tens of MB on long-running installs).
const MAX_LIMIT = 500;
const DEFAULT_LIMIT = 50;
// ── DC-052: Distinct contexts endpoint ─────────────────────────────────
// The frontend uses this to populate the "Context" dropdown so operators
// can drill into one subsystem (e.g. all "updater" or "http" errors).
router.get('/error-logs/contexts', asyncHandler(async (req, res) => {
if (!await exists(ERROR_LOG_FILE)) {
return success(res, { contexts: [] });
}
const logContent = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
const entries = parseEntries(logContent);
const counts = new Map();
for (const e of entries) {
if (!e.context) continue;
counts.set(e.context, (counts.get(e.context) || 0) + 1);
}
const contexts = Array.from(counts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
success(res, { contexts });
}, 'error-logs-contexts'));
// ── DC-052: Enhanced GET /error-logs ───────────────────────────────────
// Get error logs
// GET /api/v1/error-logs?tail=100&level=ERR
// - tail: cap on returned entries (default 100, max 500)
// - level: filter by level (ERR/WARN/INFO/DBG) — case-insensitive
router.get('/error-logs', asyncHandler(async (req, res) => {
const level = (req.query.level || '').toString().trim();
const context = (req.query.context || '').toString().trim();
const search = (req.query.search || '').toString().trim();
let since, until;
try {
since = parseTimestamp(req.query.since, 'since');
until = parseTimestamp(req.query.until, 'until');
} catch (e) {
return errorResponse(res, e.message, 400);
}
if (level && !['ERR', 'WARN', 'INFO', 'DEBUG'].includes(level)) {
return errorResponse(res, `Unknown level: ${level}`, 400);
}
const limit = Math.min(
Math.max(parseInt(req.query.limit, 10) || DEFAULT_LIMIT, 1),
MAX_LIMIT
);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
if (!await exists(ERROR_LOG_FILE)) {
return success(res, {
logs: [],
total: 0,
hasMore: false,
filters: { level: level || null, context: context || null, search: search || null, since: null, until: null },
});
return success(res, { logs: [], totalSize: 0, truncated: false });
}
const logContent = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
let entries = parseEntries(logContent);
let tailRaw = parseInt(req.query.tail, 10);
if (!Number.isFinite(tailRaw) || tailRaw <= 0) tailRaw = 100;
const tail = Math.min(tailRaw, MAX_TAIL);
// Filter chain — order matters: the cheapest predicate runs first so we
// skip work on entries the others would also reject.
if (level) entries = entries.filter((e) => e.level === level);
if (context) entries = entries.filter((e) => (e.context || '').includes(context));
if (since != null) entries = entries.filter((e) => e._rawTimestamp >= since);
if (until != null) entries = entries.filter((e) => e._rawTimestamp <= until);
if (search) {
const needle = search.toLowerCase();
entries = entries.filter((e) => {
if ((e.error || '').toLowerCase().includes(needle)) return true;
if ((e.context || '').toLowerCase().includes(needle)) return true;
if (e.detail && e.detail.toLowerCase().includes(needle)) return true;
if (e.request && e.request.ip && e.request.ip.toLowerCase().includes(needle)) return true;
return false;
});
const levelFilter = req.query.level ? String(req.query.level).toUpperCase() : null;
const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES);
let logs = parseEntries(text);
if (levelFilter) {
logs = logs.filter(e => e.level === levelFilter);
}
// Sort newest first; entries without a parseable timestamp sink to the
// bottom (Date.parse returns NaN → _rawTimestamp=0).
entries.sort((a, b) => b._rawTimestamp - a._rawTimestamp);
// Newest first; bounded by `tail`
logs = logs.slice(-tail).reverse();
const total = entries.length;
const page = entries.slice(offset, offset + limit);
// Strip the internal field so it doesn't leak into the wire response.
const logs = page.map(({ _rawTimestamp, ...rest }) => rest);
success(res, {
logs,
total,
hasMore: offset + logs.length < total,
filters: {
level: level || null,
context: context || null,
search: search || null,
since: req.query.since || null,
until: req.query.until || null,
},
});
success(res, { logs, totalSize, truncated, returned: logs.length });
}, 'error-logs-get'));
// Clear error logs (gated by confirm=CLEAR — DC-052)
// Clear error logs
router.delete('/error-logs', asyncHandler(async (req, res) => {
const confirm = (req.body && req.body.confirm) || '';
if (confirm !== 'CLEAR') {
return errorResponse(res, 'Body must include { confirm: "CLEAR" }', 400);
if (!await exists(ERROR_LOG_FILE)) {
return success(res, { message: 'Error logs cleared', cleared: 0 });
}
if (await exists(ERROR_LOG_FILE)) {
await fsp.writeFile(ERROR_LOG_FILE, '');
}
// Audit the clear BEFORE returning so the wipe itself is recorded.
try {
if (auditLogger && typeof auditLogger.log === 'function') {
await auditLogger.log({
action: 'error-log.clear',
resource: 'all',
outcome: 'success',
details: { source: 'error-logs/DELETE' },
});
}
} catch { /* don't fail the clear on audit failure */ }
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'));
// DC-052 fix: removed the legacy /audit-logs GET/DELETE proxies that lived
// here before DC-050. GLM judge round-1 flagged this as HIGH-severity:
// because errorLogsRoutes is mounted in src/app.js (line 733) BEFORE
// auditLogRoutes (line 789), these legacy handlers shadowed DC-050's
// hardened versions — DELETE without confirm=CLEAR would silently wipe the
// audit log, GET filters (action whitelist, ISO since/until, outcome) were
// never invoked, and /audit-logs/actions was unreachable. The hardened
// handlers in routes/audit-log.js are the single source of truth now.
// Audit log
router.get('/audit-logs', asyncHandler(async (req, res) => {
const paginationParams = parsePaginationParams(req.query);
const action = req.query.action || '';
if (paginationParams) {
const entries = await auditLogger.query({ limit: Number.MAX_SAFE_INTEGER, offset: 0, action });
const result = paginate(entries, paginationParams);
success(res, { entries: result.data, pagination: result.pagination });
} else {
const limit = parseInt(req.query.limit) || 50;
const offset = parseInt(req.query.offset) || 0;
const entries = await auditLogger.query({ limit, offset, action });
success(res, { entries });
}
}, 'audit-log'));
router.delete('/audit-logs', asyncHandler(async (req, res) => {
await auditLogger.clear();
success(res, { message: 'Audit log cleared' });
}, 'audit-log-clear'));
return router;
};
// Exported for unit testing
module.exports.parseEntries = parseEntries;
module.exports.readTailBytes = readTailBytes;
module.exports.MAX_TAIL = MAX_TAIL;
module.exports.MAX_TAIL_BYTES = MAX_TAIL_BYTES;
-102
View File
@@ -6,15 +6,6 @@ const { exists } = require('../src/utilities/fs-helpers');
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
const { NotFoundError, ValidationError, ForbiddenError } = require('../src/utilities/errors');
const { ok } = require('../src/utils/responses');
const journald = require('../src/monitoring/journald-reader');
const journaldAvailable = (() => {
try {
return fs.existsSync('/var/log/journal') && fs.existsSync('/usr/bin/journalctl');
} catch (_) {
return false;
}
})();
/**
* Logs route factory
@@ -227,99 +218,6 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
ok(res, { result });
}, 'logs-docker-maintenance'));
// ===== DC-055: Host journald log viewer =====
// Reads from the host's /var/log/journal via bind-mount in start.sh.
// Returns 503 if the bind-mount isn't present (dev containers, Windows).
// Allow-list of units the dashboard can stream. Exposed to the client so
// the dropdown stays in sync with the server-side allow-list.
router.get('/logs/journal/units', asyncHandler(async (req, res) => {
if (!journaldAvailable) {
return ok(res, { available: false, units: [] });
}
const units = await journald.listUnits();
ok(res, { available: true, units });
}, 'logs-journal-units'));
// Read a bounded tail of entries for a unit.
router.get('/logs/journal', asyncHandler(async (req, res) => {
if (!journaldAvailable) {
throw new Error('journald not mounted in this container (host /var/log/journal + /usr/bin/journalctl required)');
}
const entries = await journald.readEntries({
unit: req.query.unit,
tail: req.query.tail,
since: req.query.since,
until: req.query.until,
search: req.query.search,
});
ok(res, { entries, count: entries.length });
}, 'logs-journal-read'));
// Stream entries as they arrive (Server-Sent Events).
router.get('/logs/journal/stream', asyncHandler(async (req, res) => {
if (!journaldAvailable) {
res.statusCode = 503;
res.setHeader('Content-Type', 'text/event-stream');
res.write(`data: ${JSON.stringify({ error: 'journald not mounted in this container' })}\n\n`);
res.end();
return;
}
// Validate BEFORE writing SSE headers — once headers go out we
// can't change statusCode. The reader does the same validation but
// we want to short-circuit here so the response status reflects the
// right category (400 for validation, 503 for bind-mount missing).
try {
journald.assertUnitAllowed(req.query.unit);
if (req.query.since) journald.parseTimestamp(req.query.since, 'since');
} catch (err) {
// Pass through the global error middleware so the response status
// + shape matches every other validation error in the API.
throw err;
}
// SSE headers — same convention as /logs/stream/:id.
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
let settled = false;
const cleanup = (handle) => {
if (settled) return;
settled = true;
try { handle && handle.kill(); } catch (_) { /* already dead */ }
try { res.end(); } catch (_) { /* already closed */ }
};
let handle;
try {
handle = journald.streamEntries(
{ unit: req.query.unit, since: req.query.since, search: req.query.search },
{
onData(entry) {
if (settled) return;
res.write(`data: ${JSON.stringify(entry)}\n\n`);
},
onError(err) {
if (settled) return;
res.write(`data: ${JSON.stringify({ error: err.message || String(err) })}\n\n`);
cleanup(handle);
},
}
);
} catch (err) {
res.write(`data: ${JSON.stringify({ error: (err && err.message) || 'stream failed' })}\n\n`);
try { res.end(); } catch (_) { /* ignore */ }
return;
}
// Modern Node fires 'close' for both clean disconnects and aborts;
// the separate 'aborted' listener is deprecated as of Node 18.
req.on('close', () => cleanup(handle));
}, 'logs-journal-stream'));
// Get logs from a file path (for native applications)
router.get('/logs/file', asyncHandler(async (req, res) => {
const { path: logPath, tail = 100 } = req.query;
@@ -52,34 +52,6 @@ const STATE_FILE = process.env.CADDY_UPSTREAMS_STATE_FILE
const SITES_DIR = process.env.CADDY_SITES_DIR || '/etc/caddy/sites';
/**
* Hostname the probe uses instead of a loopback address.
*
* CRITICAL: this watcher runs INSIDE the dashcaddy-api container. Caddy runs
* on the HOST. A site config's `reverse_proxy localhost:8088` means "the
* host's loopback" from Caddy's point of view — but from inside the container
* `localhost`/`127.0.0.1` is the container's OWN loopback, where nothing
* listens. Probing loopback verbatim makes every healthy host-side upstream
* report ECONNREFUSED (live prod bug 2026-08-18: 9 of 14 tracked upstreams
* showed 278 consecutive phantom failures and opened bogus `caddy-upstream-dead`
* incidents).
*
* Fix: remap loopback probe targets to `host.docker.internal`, which start.sh
* pins to the host's bridge IP via `--add-host=host.docker.internal:host-gateway`
* (Docker ≥ 20.10). The upstream's display key stays `localhost:PORT` so
* existing mute lists and UI labels are unaffected — only the probe target
* changes. Set IN_CONTAINER=false (e.g. a bare-metal deployment where the API
* runs beside Caddy) to disable the remap.
*/
const HOST_GATEWAY_NAME = process.env.CADDY_UPSTREAM_HOST_GATEWAY_NAME || 'host.docker.internal';
const IN_CONTAINER = process.env.IN_CONTAINER !== 'false';
const HOST_GATEWAY_PROBE = IN_CONTAINER ? HOST_GATEWAY_NAME : null;
/** True when the address is IPv4 loopback (127.0.0.0/8) or the `localhost` name. */
function isLoopbackHost(host) {
return host === 'localhost' || /^127(\.\d{1,3}){3}$/.test(host);
}
class CaddyUpstreamWatcher extends EventEmitter {
constructor(opts = {}) {
super();
@@ -223,12 +195,7 @@ class CaddyUpstreamWatcher extends EventEmitter {
/** Probe a single upstream and update state. */
async _probeOne(u) {
// Loopback upstreams (see HOST_GATEWAY_PROBE header comment): the Caddyfile
// `localhost`/`127.x` is host-relative, so probe the host gateway instead of
// the container's own loopback. Display key and persisted `ip` are unchanged.
const loopbackRemap = !!(HOST_GATEWAY_PROBE && isLoopbackHost(u.ip));
const probeHost = loopbackRemap ? HOST_GATEWAY_PROBE : u.ip;
const result = await this._doProbe(probeHost, u.port);
const result = await this._doProbe(u.ip, u.port);
u.lastCheckedAt = new Date().toISOString();
if (result.healthy) {
@@ -242,41 +209,6 @@ class CaddyUpstreamWatcher extends EventEmitter {
// to be stable. After one full successful check we mark 'up' but the
// incident resolution waits for RESOLVED_AFTER_MS.
u.status = 'up';
// A successful host-gateway probe PROVES the bridge can reach the
// host. If a later probe then fails, we have strong evidence the
// upstream itself went dead — not that bridge connectivity broke.
// Mark verifiedViaBridge so the unverifiable path can short-circuit
// and treat it like a non-loopback upstream.
if (loopbackRemap) u.verifiedViaBridge = true;
} else if (loopbackRemap && !u.verifiedViaBridge) {
// The host-gateway probe comes from the docker bridge IP. A service
// bound to 0.0.0.0 on the host answers; a service bound to the host's
// 127.0.0.1 ONLY refuses — indistinguishable, from this vantage point,
// from a truly dead service. Caddy (on the host) reaches both fine, so
// a failed probe here is NOT evidence the upstream is dead. Mark it
// unverifiable: no failure counters, no incident, keep lastError for
// visibility. (A successful probe IS conclusive — see above.)
u.consecutiveFailures = 0;
u.status = 'unverifiable';
u.lastError = `host-loopback upstream not verifiable from container (${result.error || `HTTP ${result.statusCode || 'unknown'}`})`;
// Clear the success anchor: a 10-minute-old success is not evidence of
// anything for an upstream we cannot observe from this vantage point,
// and leaving it would make snapshot() compute a bogus failingForMs
// and flag `dead`.
u.lastSuccessAt = null;
this._maybeResolve(u);
} else if (loopbackRemap && u.verifiedViaBridge) {
// The bridge previously reached this upstream successfully — so a
// failed probe here is near-conclusive evidence the upstream itself
// went dead (the bridge path itself doesn't change between probes).
// Treat it like a non-loopback upstream failure: count it, open an
// incident after DEAD_AFTER_MS. This restores dead-detection for the
// subset of loopback upstreams that prove themselves reachable.
u.consecutiveFailures += 1;
u.lastFailureAt = u.lastCheckedAt;
u.lastError = result.error || `HTTP ${result.statusCode || 'unknown'}`;
u.status = 'down';
this._maybeOpenIncident(u);
} else {
u.consecutiveFailures += 1;
u.lastFailureAt = u.lastCheckedAt;
@@ -373,25 +305,7 @@ class CaddyUpstreamWatcher extends EventEmitter {
});
}
/**
* Public snapshot for the API/UI.
*
* Each upstream record includes:
* - host / site / siteFile: identity
* - status: 'up' | 'down' | 'unverifiable' | 'unknown' (or 'muted' here)
* - consecutiveFailures / failingForMs: dead-detection counters
* - lastCheckedAt / lastSuccessAt / lastFailureAt / lastError: probe history
* - muted: true if user silenced this upstream
* - dead: true if failingForMs >= DEAD_AFTER_MS (5 min default)
* - verifiedViaBridge (loopback upstreams only): true iff this upstream
* has ever answered a host-gateway probe with success. A later failed
* probe is then near-conclusive evidence of upstream death rather
* than bridge/UFW refusal. UI consumers should label `unverifiable`
* rows as "no prior observation" and `down` rows with
* verifiedViaBridge=true as "previously-verified, now down".
*
* @returns {{ upstreams: Array<object>, config: object }}
*/
/** Public snapshot for the API/UI. */
snapshot() {
const list = [];
for (const u of this.upstreams.values()) {
@@ -420,18 +334,12 @@ class CaddyUpstreamWatcher extends EventEmitter {
lastError: u.lastError,
failingForMs: failingFor,
muted,
dead: !muted && failingFor >= DEAD_AFTER_MS,
// True iff this loopback upstream has ever answered a host-gateway
// probe with success — meaning we have at least one prior positive
// observation of bridge connectivity, so a later failure is
// evidence of upstream death rather than bridge/UFW refusal.
verifiedViaBridge: !!u.verifiedViaBridge
dead: !muted && failingFor >= DEAD_AFTER_MS
});
}
// Sort: dead first, then down, then muted, then unverifiable (informational),
// then up, then unknown. Within each, by host.
// 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, unverifiable: 3, up: 4, unknown: 5 };
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;
@@ -498,16 +406,6 @@ class CaddyUpstreamWatcher extends EventEmitter {
lastSuccessAt: st.lastSuccessAt || null,
lastError: st.lastError || null,
lastCheckedAt: st.lastCheckedAt || null,
// Persist verifiedViaBridge so a loopback upstream that proved itself
// reachable once doesn't have to re-prove it after every container
// restart. A 1-tick blip is acceptable here because:
// (a) the field is only used as a labelling gate for the
// unverifiable-vs-down decision — a falsy restart value means
// we re-mark unverifiable for one cycle, the safer direction;
// (b) the bridge IP doesn't change between restarts of the same
// container, so a previously-positive observation is still
// good evidence.
verifiedViaBridge: !!st.verifiedViaBridge,
status: 'unknown'
});
}
@@ -528,8 +426,7 @@ class CaddyUpstreamWatcher extends EventEmitter {
lastFailureAt: v.lastFailureAt,
lastSuccessAt: v.lastSuccessAt,
lastError: v.lastError,
lastCheckedAt: v.lastCheckedAt,
verifiedViaBridge: !!v.verifiedViaBridge
lastCheckedAt: v.lastCheckedAt
};
}
const tmp = STATE_FILE + '.tmp';
@@ -1,417 +0,0 @@
/**
* DC-055: Host journald reader
*
* Wraps the host's `journalctl` binary so the API can stream host service
* logs (caddy, dashcaddy-api, docker, ...) without exposing the binary
* directly to the web layer. The CLI is invoked with --directory pointed at
* the bind-mounted /var/log/journal from start.sh so we don't need the
* systemd-journal remote protocol or a privileged socket.
*
* Security contract:
* - `unit` MUST be in the allow-list `ALLOWED_UNITS`. We never accept a
* raw unit name from the caller and pass it to the shell, even with
* shell:false — because an attacker who can set unit=caddy.service;
* touch /tmp/x could use the CLI itself as a confused-deputy vector.
* - All journalctl invocations use `spawn` (not `exec`) and pass arguments
* as an array (`shell:false`). No shell metacharacters can be smuggled
* in through any field — the unit, since/until, search, tail numbers
* are validated separately before being added to argv.
* - Streams (SSE) cap to MAX_STREAM_BYTES and kill the child on overflow
* so a `tail=999999999999` request can't OOM the process.
*
* Failure modes that surface to the route layer:
* - journalctl missing in the container (DN container, dev container):
* every call throws Error('journalctl unavailable'). Route 503s.
* - unit not in allow-list: throws ValidationError. Route 400s.
* - non-zero exit code: child stderr is captured and surfaced verbatim
* up to LOG_PREVIEW_BYTES so the operator can see "Failed to open
* directory" instead of a generic 500.
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const JOURNAL_DIR = '/var/log/journal';
const ALLOWED_UNITS = Object.freeze([
// Core reverse proxy + DNS host services
'caddy',
'dashcaddy-api',
'docker',
'systemd-journald',
'networkd-dispatcher',
'tailscaled',
'ssh',
// Permit the unit with and without the .service suffix. The CLI accepts
// both; we store the bare name and append nothing — journalctl treats
// "caddy" and "caddy.service" identically.
]);
// Cap how much a single request can read — prevents `tail=999999999` from
// piping half the journal into memory. The dashboard doesn't have a UI for
// "load 100MB of logs" and journalctl itself caps at 2GB anyway.
const MAX_TAIL_LINES = 5000;
// Streaming cap: how many journal entries we hand to the SSE consumer
// before killing the child. The dashboard shouldn't accumulate more than
// this in memory — pair with MAX_OUTPUT_BUFFER for a defense-in-depth
// bound on what the route layer will hold.
const MAX_STREAM_LINES = 5000;
const MAX_OUTPUT_BUFFER = 2 * 1024 * 1024; // 2MB hard cap on total stdout
const LOG_PREVIEW_BYTES = 4096;
const UNIT_PATTERN = /^[a-zA-Z0-9_.@-]+$/;
const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
/**
* Validate a unit name against the allow-list. Returns the canonical name
* or throws ValidationError.
*/
function assertUnitAllowed(unit) {
if (typeof unit !== 'string' || !unit) {
const err = new Error('unit is required');
err.name = 'ValidationError';
throw err;
}
// Strip the .service suffix defensively so callers don't have to remember
// which form journalctl prefers for a given unit.
const normalised = unit.endsWith('.service') ? unit.slice(0, -8) : unit;
if (!UNIT_PATTERN.test(normalised)) {
const err = new Error(`unit contains invalid characters: ${unit}`);
err.name = 'ValidationError';
throw err;
}
if (!ALLOWED_UNITS.includes(normalised)) {
const err = new Error(`unit not in allow-list: ${normalised}`);
err.name = 'ValidationError';
throw err;
}
return normalised;
}
/**
* Parse tail to a bounded positive integer.
*/
function parseTail(raw, fallback = 200) {
if (raw === undefined || raw === null || raw === '') return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
const err = new Error(`tail must be a positive integer (got ${raw})`);
err.name = 'ValidationError';
throw err;
}
return Math.min(n, MAX_TAIL_LINES);
}
/**
* Parse since/until — accept either an ISO timestamp, a unix epoch in ms, or
* journalctl's relative syntax ("30 min ago", "today", "yesterday"). The
* dashboard uses ISO timestamps from `<input type="datetime-local">`; the
* relative syntax is for power users typing into the search bar.
*/
function parseTimestamp(raw, fieldName) {
if (raw === undefined || raw === null || raw === '') return null;
if (typeof raw !== 'string') {
const err = new Error(`${fieldName} must be a string`);
err.name = 'ValidationError';
throw err;
}
// ISO 8601
if (ISO_PATTERN.test(raw)) {
const ms = Date.parse(raw);
if (!Number.isFinite(ms)) {
const err = new Error(`${fieldName} is not a valid ISO timestamp: ${raw}`);
err.name = 'ValidationError';
throw err;
}
return new Date(ms).toISOString();
}
// Numeric (unix epoch seconds OR ms — journalctl accepts seconds)
if (/^-?\d+$/.test(raw)) {
const n = Number(raw);
const ms = n > 1e12 ? n : n * 1000;
if (!Number.isFinite(ms)) {
const err = new Error(`${fieldName} is not a valid epoch: ${raw}`);
err.name = 'ValidationError';
throw err;
}
return new Date(ms).toISOString();
}
// Relative syntax: pass through to journalctl, but cap to 1024 chars and
// disallow shell metacharacters.
if (raw.length > 1024 || /[`$;&|><\\\n\r]/.test(raw)) {
const err = new Error(`${fieldName} contains forbidden characters: ${raw}`);
err.name = 'ValidationError';
throw err;
}
return raw;
}
/**
* Detect whether journalctl is reachable. Cheap probe (no-op flag) so we
* don't shell out on every request when the binary is missing (dev
* container, Windows host, etc.).
*/
function isAvailable({ journalDir = JOURNAL_DIR, exec = spawn } = {}) {
if (!fs.existsSync(journalDir)) return false;
return new Promise((resolve) => {
const child = exec('journalctl', ['--no-pager', '--version'], { stdio: 'ignore' });
child.on('error', () => resolve(false));
child.on('exit', (code) => resolve(code === 0));
});
}
/**
* Build argv for journalctl. Exposed so tests can assert exactly what we
* shell out — never build the arg array inline anywhere else.
*/
function buildArgv({ unit, since, until, tail, search, follow = false }) {
const argv = [
'--directory', JOURNAL_DIR,
'--no-pager',
'--output=short',
'-u', unit,
];
if (since) argv.push('--since', since);
if (until) argv.push('--until', until);
if (typeof tail === 'number') argv.push('-n', String(tail));
if (search) {
// journalctl -S matches the searchable text fields (MESSAGE + others).
// Quote-enforcing isn't needed because spawn argv doesn't touch a shell.
argv.push('-S', search);
}
if (follow) argv.push('--follow');
return argv;
}
/**
* Read a bounded tail of journal entries for a unit. Resolves to an array
* of {timestamp, text} lines, oldest first. Throws ValidationError on bad
* input, Error('journalctl unavailable') if the binary or journal dir is
* missing, and Error('journalctl exited N: <stderr>') for CLI failures.
*/
/**
* Spawn journalctl with the given argv and collect stdout/stderr up to
* the configured caps. Resolves to a Buffer of stdout on success, rejects
* with Error('journalctl unavailable') on ENOENT or
* Error('journalctl exited N: <stderr>') on non-zero exit. Exceeding the
* output cap rejects with an explicit overflow message.
*
* Kept as a free function (not inside `readEntries`) so the same plumbing
* can be reused for streaming without code duplication.
*/
function runJournalctl({ exec, argv }) {
return new Promise((resolve, reject) => {
const child = exec('journalctl', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = Buffer.alloc(0);
let stderr = '';
child.stdout.on('data', (chunk) => {
if (stdout.length + chunk.length > MAX_OUTPUT_BUFFER) {
child.kill('SIGKILL');
reject(new Error(`output exceeded ${MAX_OUTPUT_BUFFER} bytes`));
return;
}
stdout = Buffer.concat([stdout, chunk]);
});
child.stderr.on('data', (chunk) => {
if (stderr.length < LOG_PREVIEW_BYTES) {
stderr += chunk.toString('utf8');
if (stderr.length > LOG_PREVIEW_BYTES) {
stderr = stderr.slice(0, LOG_PREVIEW_BYTES) + '…';
}
}
});
child.on('error', (err) => {
if (err.code === 'ENOENT') {
reject(new Error('journalctl unavailable'));
} else {
reject(err);
}
});
child.on('exit', (code, signal) => {
if (signal === 'SIGKILL' && stdout.length >= MAX_OUTPUT_BUFFER) return; // already rejected
if (code !== 0) {
reject(new Error(`journalctl exited ${code}${stderr ? ': ' + stderr.trim() : ''}`));
return;
}
resolve({ stdout, stderr });
});
});
}
/**
* Parse a journalctl --output=short line into a structured entry.
* Lines look like: "Aug 18 00:42:46 vmi3080415 caddy[3620580]: {...}"
*/
function parseShortLine(line, fallbackUnit) {
const tsMatch = line.match(/^([A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}) (\S+) (.+?)\[\d+\]: (.*)$/);
if (tsMatch) {
return {
timestamp: tsMatch[1],
hostname: tsMatch[2],
unit: tsMatch[3],
text: tsMatch[4],
};
}
return { timestamp: null, hostname: null, unit: fallbackUnit, text: line };
}
function readEntries(opts, { exec = spawn } = {}) {
return Promise.resolve().then(async () => {
const unit = assertUnitAllowed(opts.unit);
const tail = parseTail(opts.tail);
const since = parseTimestamp(opts.since, 'since');
const until = parseTimestamp(opts.until, 'until');
const search = typeof opts.search === 'string' && opts.search.length > 0
? opts.search.slice(0, 1024)
: null;
const argv = buildArgv({ unit, tail, since, until, search, follow: false });
const { stdout } = await runJournalctl({ exec, argv });
const lines = stdout.toString('utf8').split('\n').filter(Boolean);
return lines.map((line) => parseShortLine(line, unit));
});
}
/**
* Stream journal entries as they arrive. Returns { child, onData, onError,
* kill } — the route wires `onData`/`onError` to the SSE socket and calls
* `kill()` on disconnect.
*
* The child is spawned with --follow and we cap total bytes received; on
* overflow we kill the child and emit a synthetic 'overflow' message so the
* client knows to reconnect with a narrower window.
*/
function streamEntries(opts, { exec = spawn, onData, onError } = {}) {
const unit = assertUnitAllowed(opts.unit);
const since = parseTimestamp(opts.since, 'since');
const search = typeof opts.search === 'string' && opts.search.length > 0
? opts.search.slice(0, 1024)
: null;
const argv = buildArgv({ unit, since, search, follow: true });
let child;
try {
child = exec('journalctl', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
} catch (err) {
if (err.code === 'ENOENT') {
const e = new Error('journalctl unavailable');
onError && onError(e);
return { kill: () => {}, child: null };
}
throw err;
}
// Closure-scoped stream bookkeeping: the previous version attached a
// counter to the onData function itself, which made the 5000-line cap
// unreachable (a function has its own properties — the count was never
// incremented). Closure scope is the right place.
let stdout = Buffer.alloc(0);
let lineCount = 0;
let overflowEmitted = false;
child.stdout.on('data', (chunk) => {
if (stdout.length + chunk.length > MAX_OUTPUT_BUFFER) {
child.kill('SIGKILL');
onError && onError(new Error(`stream exceeded ${MAX_OUTPUT_BUFFER} bytes`));
return;
}
stdout = Buffer.concat([stdout, chunk]);
if (onData) {
const text = stdout.toString('utf8');
const lines = text.split('\n');
// Hold back the last partial line; flush on the next chunk or exit.
stdout = Buffer.from(lines.pop(), 'utf8');
for (const line of lines) {
if (!line) continue;
lineCount++;
if (lineCount > MAX_STREAM_LINES && !overflowEmitted) {
overflowEmitted = true;
child.kill('SIGKILL');
onError && onError(new Error(`stream exceeded ${MAX_STREAM_LINES} lines`));
return;
}
const tsMatch = line.match(/^([A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}) (\S+) (.+?)\[\d+\]: (.*)$/);
onData({
timestamp: tsMatch ? tsMatch[1] : null,
hostname: tsMatch ? tsMatch[2] : null,
unit: tsMatch ? tsMatch[3] : unit,
text: tsMatch ? tsMatch[4] : line,
});
}
}
});
let stderr = '';
child.stderr.on('data', (chunk) => {
if (stderr.length < LOG_PREVIEW_BYTES) {
stderr += chunk.toString('utf8');
if (stderr.length > LOG_PREVIEW_BYTES) {
stderr = stderr.slice(0, LOG_PREVIEW_BYTES) + '…';
}
}
});
child.on('error', (err) => {
onError && onError(err);
});
child.on('exit', (code) => {
if (code !== 0 && stderr) {
onError && onError(new Error(`journalctl exited ${code}: ${stderr.trim()}`));
}
});
return {
child,
kill() {
try { child.kill('SIGTERM'); } catch (_) { /* already dead */ }
},
};
}
/**
* List units that currently have journal entries (for the dashboard
* dropdown). Walks the allow-list and asks journalctl for the most recent
* entry per unit. Units with no entries are omitted.
*/
async function listUnits({ exec = spawn } = {}) {
if (!fs.existsSync(JOURNAL_DIR)) return [];
const out = [];
for (const unit of ALLOWED_UNITS) {
const lines = await new Promise((resolve) => {
const child = exec('journalctl', [
'--directory', JOURNAL_DIR,
'--no-pager', '-q',
'-u', unit,
'-n', '1',
'--output=short',
], { stdio: ['ignore', 'pipe', 'ignore'] });
let buf = '';
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
child.on('error', () => resolve(''));
child.on('exit', () => resolve(buf));
});
if (lines.trim()) {
out.push({ unit, hasEntries: true });
}
}
return out;
}
module.exports = {
ALLOWED_UNITS,
MAX_TAIL_LINES,
MAX_OUTPUT_BUFFER,
isAvailable,
readEntries,
streamEntries,
listUnits,
assertUnitAllowed,
parseTail,
parseTimestamp,
parseShortLine,
buildArgv,
};
+2 -18
View File
@@ -214,30 +214,14 @@ function csrfValidationMiddleware(req, res, next) {
return next();
}
// DC-058: differentiate "browser auto-retry" from "real probe" using the
// X-CSRF-Token header as a signal. The dashboard JS in status/js/globals.js
// secureFetch() pre-fetches /api/v1/csrf-token (which sets the CSRF cookie
// via csrfCookieMiddleware) before posting; if the GET raced with container
// restart OR the user cleared cookies mid-session, the POST can arrive with
// a header but no cookie. secureFetch catches the 403 and auto-retries
// with a fresh token (lines 225-238 of globals.js). For these "has header
// but no cookie" misses, tag the log line [CSRF-debug] — operators can
// grep them out as expected noise. A request with NEITHER cookie NOR
// header (curl probe, exploit scanner, broken client) keeps the louder
// [CSRF] tag.
// Validate both values exist
if (!cookieNonce) {
const isLikelyBrowserAutoRetry = !!headerToken;
const tag = isLikelyBrowserAutoRetry ? '[CSRF-debug]' : '[CSRF]';
process.stderr.write(`${tag} Missing CSRF cookie: ${method} ${req.path} from ${req.ip}` +
(isLikelyBrowserAutoRetry ? ' (browser auto-retry — header present, expect self-heal)' : '') + '\n');
process.stderr.write(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}\n`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.'
});
}
// Cookie present but no header — a real browser POST always sends both, so
// header-less is suspicious (curl probe with manual cookie, misconfigured
// client). Keep WARN level.
if (!headerToken) {
process.stderr.write(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}\n`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
+1 -18
View File
@@ -118,33 +118,16 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
/**
* Raw http.request wrapper for Caddy admin API
*
* Auto-injects `Origin: http://<host>:<port>` because Caddy's admin API on a
* non-loopback bind (e.g. `admin 0.0.0.0:2019` so the DashCaddy docker
* container can probe it from 172.17.0.1) enables `enforce_origin` and
* rejects every request whose Origin isn't in the admin's `origins` allowlist
* OR is empty. Node's undici fetch sets `Sec-Fetch-Mode: cors` which triggers
* the check; raw http.request sets no Origin at all, which fails the empty
* check. Setting Origin to the admin endpoint's own origin satisfies
* gorilla/csrf same-origin and is the documented override.
* (See: https://caddyserver.com/docs/caddyfile/options — `origins` directive.)
*
* Caller-provided `Origin` header (via opts.headers) wins so tests / future
* proxies can override; default matches the parsed admin URL.
*/
function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const defaultOrigin = `${parsed.protocol}//${parsed.hostname}:${parsed.port || 2019}`;
const options = {
hostname: parsed.hostname,
port: parsed.port || 2019,
path: parsed.pathname + parsed.search,
method: (opts.method || 'GET').toUpperCase(),
headers: {
Origin: defaultOrigin,
...opts.headers,
},
headers: { ...opts.headers },
timeout: timeoutMs,
};
+2 -68
View File
@@ -112,78 +112,12 @@ async function appendErrorLog(line) {
}
}
// Flatten an error chain into readable lines so error.log records why a
// request failed, not just that it did. Handle AggregateError (`.errors[]`,
// common from lookups/DNS-fetch timeouts) and the modern `.cause` chain —
// both common in Node 18+ networking. Always returns at least one line
// (a head line with `name [code]: message`), and appends cause lines for
// any `.errors` / `.cause` chains present.
//
// Defensive against:
// - Circular `.cause` references (a pathological error payload pointing
// `err.cause = err` would otherwise infinite-recurse and crash the
// error-path). Visited set carries forward via parameter.
// - Excessively deep chains (> MAX_CHAIN_DEPTH): truncated with a marker
// so the operator can see something IS coming from underneath.
const MAX_CHAIN_DEPTH = 16;
function describeErrorChain(err, depth = 0, seen = new WeakSet()) {
const out = [];
if (depth > MAX_CHAIN_DEPTH) {
out.push(`${' '.repeat(depth)} ... (chain truncated at depth ${MAX_CHAIN_DEPTH})`);
return out;
}
if (!(err instanceof Error)) {
out.push(`${' '.repeat(depth)}${String(err)}`);
return out;
}
// Cycle guard — same Error instance already on the chain.
if (seen.has(err)) {
out.push(`${' '.repeat(depth)} ... (cycle: same Error instance seen earlier)`);
return out;
}
seen.add(err);
const indent = ' '.repeat(depth);
const code = err.code ? ` [${err.code}]` : '';
const msg = err.message ? `: ${err.message}` : '';
// For every error (including AggregateError), render the head line; an
// empty `.message` simply produces `Name [code]:` which is still useful.
out.push(`${indent}${err.name || 'Error'}${code}${msg}`);
if (Array.isArray(err.errors) && err.errors.length) {
err.errors.forEach((sub, i) => {
out.push(`${indent} cause #${i + 1}:`);
out.push(...describeErrorChain(sub, depth + 2, seen));
});
}
if (err.cause instanceof Error) {
out.push(`${indent} cause:`);
out.push(...describeErrorChain(err.cause, depth + 2, seen));
}
return out;
}
async function writeErrorLog(ctx, error, req, extra) {
const ts = new Date().toISOString();
const errMsg = error instanceof Error ? error.message : String(error);
const errStack = error instanceof Error ? error.stack : '';
// Build the head line AND a tail diagnostic from the same describeErrorChain,
// so plain errors with .code get `[CODE]` formatted into the head (regression)
// and AggregateError with empty `.message` gets a diagnostic block listing
// every cause (the actual bug fix).
let headLine;
let diagLines = [];
if (error instanceof Error) {
const chain = describeErrorChain(error);
// The chain head is always the error itself (now including AggregateError),
// so chain[0] is what we want in the headline and chain[1..] is the rest.
headLine = chain[0] || `${error.name || 'Error'}`;
diagLines = chain.slice(1);
} else {
headLine = String(error);
}
// Preserve the historical `ctx: <head>` shape so log scrapers don't break.
// The head now carries `name [code]: message` instead of bare `.message`.
const parts = [`[${ts}] [ERR] ${ctx}: ${headLine.replace(/^\s+/, '')}`];
const parts = [`[${ts}] [ERR] ${ctx}: ${errMsg}`];
if (errStack) parts.push(errStack);
if (diagLines.length) parts.push(' diagnostic: ' + diagLines.join('\n diagnostic: '));
if (req) {
const ip = req.ip || req.socket?.remoteAddress || '';
const ua = req.get ? req.get('user-agent') : '';
-16
View File
@@ -86,20 +86,8 @@ run_image_layer_migration
# dns1.sami → DNS1 (SAMI-CLOUD-U32)
# dc-contabo-de → DashCaddy Contabo test instance
# git.dashcaddy.net → DashCaddy upstream git
# git.sami → DNS2 (NOT DNS3 — see warning above). Resolves an
# intermittent ENOTFOUND in the ssl-monitor's TLS
# handshake check (~2/h) by pinning the name in the
# container's /etc/hosts to the Caddy listener.
# ca.sami → local CA (DN2 + DN3 both have their own)
ADD_HOST_FLAGS=(
# host.docker.internal → host bridge IP (Docker host-gateway). The caddy
# upstream watcher probes Caddy site upstreams from INSIDE this container;
# `reverse_proxy localhost:PORT` in a site file means the HOST's loopback,
# so the watcher remaps loopback probe targets to this name (see
# dashcaddy-api/src/monitoring/caddy-upstream-watcher.js). Without this
# entry the probes would hit the container's own loopback and report every
# host-side upstream as dead.
--add-host=host.docker.internal:host-gateway
--add-host=dns3.sami:100.81.59.99
--add-host=gitea:100.81.59.99
--add-host=dns3-wan.sami:74.208.167.19
@@ -107,7 +95,6 @@ ADD_HOST_FLAGS=(
--add-host=dns1.sami:100.71.97.12
--add-host=dc-contabo-de:100.98.123.59
--add-host=git.dashcaddy.net:100.98.123.59
--add-host=git.sami:100.121.150.22
# ca.sami resolves via DNS to 100.121.150.22 (Caddy on DNS2). Don't pin
# to 127.0.0.1 — nothing listens on 443 inside the container, so the
# health checker would fail with ECONNREFUSED. The CA itself is a
@@ -159,7 +146,6 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
-v ${DATA_DIR}:/app/data \
-v ${BACKUPS_DIR}:/app/backups \
-v ${CADDYFILE}:/caddyfile \
-v /etc/caddy/sites:/etc/caddy/sites:ro \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${ASSETS_DIR}:/app/assets \
-v ${UPDATES_DIR}:/app/updates \
@@ -167,8 +153,6 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
-v /usr/bin/tailscale:/usr/bin/tailscale:ro \
-v /var/run/tailscale:/var/run/tailscale:ro \
-v /etc/ssl/sami-ca:/etc/ssl/sami-ca:ro \
-v /var/log/journal:/var/log/journal:ro \
-v /usr/bin/journalctl:/usr/bin/journalctl:ro \
-e NODE_ENV=production \
-e SERVICES_FILE=/app/data/services.json \
-e CONFIG_FILE=/app/data/config.json \
-4
View File
@@ -54,10 +54,6 @@ const bundles = {
JS('import-export.js'),
JS('error-logs.js'),
JS('container-logs.js'),
// DC-055: Host journald log viewer — reads /var/log/journal via the
// bind-mount added in start.sh. Self-contained modal with SSE stream
// + bounded tail read. Exposes window.openJournaldModal().
JS('journald.js'),
JS('snapshot.js'),
JS('smart-arr-connect.js'),
JS('notification-settings.js'),
+222 -314
View File
@@ -90,19 +90,19 @@
<button id="logo-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);const w=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),S=document.getElementById("logo-status"),z=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),h=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),f=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),E=DC.NAME;let I=null,O=null,C=null,j="left",N=E;z?.addEventListener("change",()=>{z.checked?(P.style.display="none",M.style.display="",I=null,O=null):(P.style.display="flex",M.style.display="none",C=null)});function R(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{R(t.target.files[0],e=>{I=e,B.src=e,S.textContent="New dark logo ready to save"})}),h?.addEventListener("change",t=>{R(t.target.files[0],e=>{O=e,A.src=e,S.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{R(t.target.files[0],e=>{C=e,B.src=e,A.src=e,S.textContent="New logo ready to save (both themes)"})});function u(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function v(t){N=t||E,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function x(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(f.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(f.src=e.customLogo,H.src=e.customLogo,B.src=e.customLogo,A.src=e.customLogo),e.isDefault||(S.textContent="Using custom logo"),e.position&&(j=e.position,u(e.position)),e.dashboardTitle&&v(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{j=t.dataset.pos,u(j)})}),document.getElementById("brand")?.addEventListener("click",()=>{I=null,O=null,C=null,D&&(D.value=""),h&&(h.value=""),T&&(T.value=""),z&&(z.checked=!1),P.style.display="flex",M.style.display="none",B.src=f.src,A.src=H.src;const t=f.src.includes("custom-logo")||H.src.includes("custom-logo");S.textContent=t?"Using custom logo":"Using default logos",u(j),$.value=N,w.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||E,e={position:j,dashboardTitle:t};z?.checked&&C?(e.dataDark=C,e.dataLight=C):(I&&(e.dataDark=I),O&&(e.dataLight=O));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(f.src=o.pathDark+i,B.src=o.pathDark+i),o.pathLight&&(H.src=o.pathLight+i,A.src=o.pathLight+i),u(j),v(t),w.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
</div>`);const f=document.getElementById("logo-modal"),I=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),E=document.getElementById("logo-status"),M=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),B=document.getElementById("logo-upload-single"),x=document.querySelector("#brand .brand-logo-dark"),$=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),T=document.getElementById("dashboard-title"),S=DC.NAME;let L=null,j=null,C=null,N="left",R=S;M?.addEventListener("change",()=>{M.checked?(P.style.display="none",z.style.display="",L=null,j=null):(P.style.display="flex",z.style.display="none",C=null)});function U(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{U(t.target.files[0],e=>{L=e,I.src=e,E.textContent="New dark logo ready to save"})}),v?.addEventListener("change",t=>{U(t.target.files[0],e=>{j=e,A.src=e,E.textContent="New light logo ready to save"})}),B?.addEventListener("change",t=>{U(t.target.files[0],e=>{C=e,I.src=e,A.src=e,E.textContent="New logo ready to save (both themes)"})});function u(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){R=t||S,document.title=R;const e=document.querySelector(".dashboard-title");e&&(e.textContent=R)}async function h(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(x.src=e.customLogoDark,I.src=e.customLogoDark),e.customLogoLight&&($.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(x.src=e.customLogo,$.src=e.customLogo,I.src=e.customLogo,A.src=e.customLogo),e.isDefault||(E.textContent="Using custom logo"),e.position&&(N=e.position,u(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{N=t.dataset.pos,u(N)})}),document.getElementById("brand")?.addEventListener("click",()=>{L=null,j=null,C=null,D&&(D.value=""),v&&(v.value=""),B&&(B.value=""),M&&(M.checked=!1),P.style.display="flex",z.style.display="none",I.src=x.src,A.src=$.src;const t=x.src.includes("custom-logo")||$.src.includes("custom-logo");E.textContent=t?"Using custom logo":"Using default logos",u(N),T.value=R,f.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=T.value.trim()||S,e={position:N,dashboardTitle:t};M?.checked&&C?(e.dataDark=C,e.dataLight=C):(L&&(e.dataDark=L),j&&(e.dataLight=j));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(x.src=o.pathDark+i,I.src=o.pathDark+i),o.pathLight&&($.src=o.pathLight+i,A.src=o.pathLight+i),u(N),m(t),f.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(f.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",S.textContent="Using default logos",I=null,O=null,C=null,$.value=E,v(E),j="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(w,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),b=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function m(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(p.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),b.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const a=new FileReader;a.onload=o=>{l=o.target.result,g.src=l,b.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();p.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),b.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),m(),x();const c=document.getElementById("settings-timezone");c&&(new MutationObserver(()=>{w.classList.contains("show")&&c.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(c,e)})()}).observe(w,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=c.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(w,B){const A=Intl.supportedValuesOf("timeZone"),S=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";w.innerHTML="";for(const z of A){const P=document.createElement("option");P.value=z,P.textContent=z.replace(/_/g," "),z===S&&(P.selected=!0),w.appendChild(P)}},(function(){let w="homelab",B=null;async function A(){try{const v=await fetch("/api/v1/config");if(v.ok&&(B=await v.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(v){console.warn("Could not fetch server config, checking localStorage fallback:",v.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const S=document.getElementById("setup-timezone");S&&window.populateTimezoneSelect(S);function z(u){document.querySelectorAll(".setup-step").forEach(x=>{x.style.display="none"});const v=document.getElementById(u);v&&(v.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let v='<div style="display: grid; gap: 20px;">';if(w==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",s=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;v+=`
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(x.src="/assets/dashcaddy-logo-dark.png",$.src="/assets/dashcaddy-logo-light.png",I.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",E.textContent="Using default logos",L=null,j=null,C=null,T.value=S,m(S),N="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),d=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(f,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),H=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let d=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function b(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),H.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}r?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const a=new FileReader;a.onload=o=>{d=o.target.result,g.src=d,H.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(t.ok){const e=await t.json();c.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),H.textContent="Using custom favicon",d=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),b(),h();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{f.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(f,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(f,I){const A=Intl.supportedValuesOf("timeZone"),E=I||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f.innerHTML="";for(const M of A){const P=document.createElement("option");P.value=M,P.textContent=M.replace(/_/g," "),M===E&&(P.selected=!0),f.appendChild(P)}},(function(){let f="homelab",I=null;async function A(){try{const m=await fetch("/api/v1/config");if(m.ok&&(I=await m.json(),I&&I.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(m){console.warn("Could not fetch server config, checking localStorage fallback:",m.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const E=document.getElementById("setup-timezone");E&&window.populateTimezoneSelect(E);function M(u){document.querySelectorAll(".setup-step").forEach(h=>{h.style.display="none"});const m=document.getElementById(u);m&&(m.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let m='<div style="display: grid; gap: 20px;">';if(f==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",H=document.getElementById("setup-ca-name")?.value?.trim()||"",r=document.getElementById("setup-dns-ip")?.value?.trim()||"",c=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;m+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${g}</div>
<div><strong>Certificate Authority:</strong> ${b}</div>
<div><strong>DNS Server:</strong> ${s}:${p}</div>
<div><strong>Certificate Authority:</strong> ${H}</div>
<div><strong>DNS Server:</strong> ${r}:${c}</div>
<div><strong>Example URLs:</strong> https://uptime${g}, https://nextcloud${g}</div>
</div>
</div>
`}else if(w==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";v+=`
`}else if(f==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";m+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
@@ -112,22 +112,22 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
<div><strong>Example URLs:</strong> http://${g}:8080, http://${g}:3000</div>
</div>
</div>
`}else if(w==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",s=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=s==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;v+=`
`}else if(f==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",H=document.getElementById("setup-public-email")?.value?.trim()||"",r=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",c=r==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;m+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${g}</div>
<div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${b}</div>
<div><strong>Routing:</strong> ${s==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${p}</div>
<div><strong>Email:</strong> ${H}</div>
<div><strong>Routing:</strong> ${r==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${c}</div>
</div>
</div>
`}const x=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";v+=`
`}const h=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";m+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${x.replace(/_/g," ")}</div>
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${h.replace(/_/g," ")}</div>
</div>
`,v+="</div>",u.innerHTML=v,z("setup-step-summary")}async function M(u){try{const v=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return v.ok?(await v.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${v.status}`),{function:"saveConfigToServer"}),!1)}catch(v){return errorHandler.logError("[SetupWizard] Save Config",v,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:w,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(w==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:b,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else w==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):w==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const v=await M(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const x=w==="homelab"?"Professional Home Lab":w==="simple"?"Simple Setup":"Public Server",g=v?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${x}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const h=document.getElementById("setup-step-1-next");h&&(h.onclick=function(u){u.preventDefault();const v=document.querySelector('input[name="config-type"]:checked');v&&(w=v.value),z(w==="homelab"?"setup-step-homelab":w==="simple"?"setup-step-simple":w==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await M({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const f=document.getElementById("setup-tld");f&&(f.oninput=function(u){const v=u.target.value||".home",x=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");x&&(x.textContent=v),g&&(g.textContent=v)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(u){u.preventDefault(),z("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-tld")?.value?.trim()||"",x=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!v||!v.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!x){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(u){u.preventDefault(),z("setup-step-1")});const E=document.getElementById("setup-simple-next");E&&(E.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var v=document.getElementById("dns-requirement-note");v&&(v.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const I=document.getElementById("setup-public-back");I&&(I.onclick=function(u){u.preventDefault(),z("setup-step-1")});const O=document.getElementById("setup-public-next");O&&(O.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-public-domain")?.value?.trim()||"",x=document.getElementById("setup-public-email")?.value?.trim()||"";if(!v){showNotification("Please enter your domain name","warning");return}if(!x||!x.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const C=document.getElementById("setup-summary-back");C&&(C.onclick=function(u){u.preventDefault(),w==="homelab"?z("setup-step-homela Line truncated
`,m+="</div>",u.innerHTML=m,M("setup-step-summary")}async function z(u){try{const m=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return m.ok?(await m.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${m.status}`),{function:"saveConfigToServer"}),!1)}catch(m){return errorHandler.logError("[SetupWizard] Save Config",m,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:f,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(f==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const H=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:H,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else f==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):f==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const m=await z(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const h=f==="homelab"?"Professional Home Lab":f==="simple"?"Simple Setup":"Public Server",g=m?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${h}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(u){u.preventDefault();const m=document.querySelector('input[name="config-type"]:checked');m&&(f=m.value),M(f==="homelab"?"setup-step-homelab":f==="simple"?"setup-step-simple":f==="public"?"setup-step-public":"setup-step-homelab")});const B=document.getElementById("setup-skip");B&&(B.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const x=document.getElementById("setup-tld");x&&(x.oninput=function(u){const m=u.target.value||".home",h=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");h&&(h.textContent=m),g&&(g.textContent=m)});const $=document.getElementById("setup-homelab-back");$&&($.onclick=function(u){u.preventDefault(),M("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-tld")?.value?.trim()||"",h=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!m||!m.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!h){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const T=document.getElementById("setup-simple-back");T&&(T.onclick=function(u){u.preventDefault(),M("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var m=document.getElementById("dns-requirement-note");m&&(m.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const L=document.getElementById("setup-public-back");L&&(L.onclick=function(u){u.preventDefault(),M("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-public-domain")?.value?.trim()||"",h=document.getElementById("setup-public-email")?.value?.trim()||"";if(!m){showNotification("Please enter your domain name","warning");return}if(!h||!h.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const C=document.getElementById("setup-summary-back");C&&(C.onclick=function(u){u.preventDefault(),f==="homelab"?M("setup-step-homela Line truncated
<div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button>
</div>
</div>
</div>`);const B="custom-apps";let A=null,S=null;const z=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function M(){try{const p=await(await fetch("/api/v1/apps/templates")).json();if(p.success)return A=p.templates,S=p.categories,!0}catch(s){w.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function D(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(p){return w.logError("[AppSelector] Check Port",p,{function:"checkPortAvailability"}),{available:!0}}}async function h(s){try{const l=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(l.success)return l.suggestedPort}catch(p){w.logError("[AppSelector] Get Suggested Port",p,{function:"getSuggestedPort"})}return s}async function T(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await M()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const s={};for(const[l,m]of Object.entries(A)){const c=m.category||"Other";s[c]||(s[c]=[]),s[c].push({id:l,...m})}const p=S?Object.keys(S):Object.keys(s).sort();for(const l of p){const m=s[l];if(!m||m.length===0)continue;m.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const c=document.createElement("div");c.className="app-category-header";const t=S?.[l]||{};c.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(l)}`,t.color&&(c.style.borderBottomColor=t.color),P.appendChild(c),m.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",r=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
</div>`);const I="custom-apps";let A=null,E=null;const M=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function z(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return A=c.templates,E=c.categories,!0}catch(r){f.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function D(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return f.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(r){try{const d=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){f.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function B(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await z()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[d,b]of Object.entries(A)){const y=b.category||"Other";r[y]||(r[y]=[]),r[y].push({id:d,...b})}const c=E?Object.keys(E):Object.keys(r).sort();for(const d of c){const b=r[d];if(!b||b.length===0)continue;b.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const t=E?.[d]||{};y.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(d)}`,t.color&&(y.style.borderBottomColor=t.color),P.appendChild(y),b.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",s=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${n}${r}
`,o?a.onclick=()=>f(e,a):a.onclick=()=>H(e),P.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function f(s,p){const l="widget-"+s.id+"-enabled",c=!(safeGet(l)!=="false");safeSet(l,String(c));const t=s.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=c?"":"none")}const e=p.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=c?"ON":"OFF",e.style.background=c?"#2ecc7130":"#e74c3c30",e.style.color=c?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${c?"enabled":"disabled"}`,"success",2e3)}async function H(s){const p=document.getElementById("app-deploy-modal"),l=document.getElementById("app-deploy-title"),m=document.getElementById("deploy-subdomain"),c=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${s.name} container:
${n}${s}
`,o?a.onclick=()=>x(e,a):a.onclick=()=>$(e),P.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function x(r,c){const d="widget-"+r.id+"-enabled",y=!(safeGet(d)!=="false");safeSet(d,String(y));const t=r.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function $(r){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),b=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${r.name} container:
Container: ${V.name}
Status: ${V.status}
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}l.textContent=`Deploy ${s.name}`;const i=s.subdomain||s.id.replace(/-/g,"");m.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const W=s.subpathSupport||"strip";W==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">&#9432; '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const r=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),d=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),y=document.querySelector(`input[name="dns-type"][value="${r}"]`),L=document.querySelector(`input[name="ssl-type"][value="${d}"]`);y?y.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,L?L.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),F=q?.querySelector("div");if(q&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const _=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){_.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else _.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const W=s.mediaMount?.containerPath,G=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}d.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");b.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const W=r.subpathSupport||"strip";W==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">&#9432; '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),p=document.querySelector(`input[name="dns-type"][value="${s}"]`),w=document.querySelector(`input[name="ssl-type"][value="${l}"]`);p?p.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,w?w.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const O=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const W=r.mediaMount?.containerPath,G=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await D(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await h(se);Y.innerHTML=`
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await D(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await v(se);Y.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span>
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?o.innerHTML=`
<span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=m.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}c.textContent=K}m.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),z.classList.remove("show"),p.classList.add("show"),p.dataset.appTemplate=JSON.stringify(s)}async function k(s){const p=s.appTemplate,l=safeGetJSON(B,[]),m=p._useExisting&&p._existingContainer,c=l.find(t=>t.id===s.subdomain);if(!(c&&!m&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(c){const t=l.indexOf(c);l.splice(t,1),safeSet(B,JSON.stringify(l))}if(m)s.port=p._existingContainer.primaryPort;else{const t=s.port||p.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await h(p.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=b.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}y.textContent=K}b.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),M.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function k(r){const c=r.appTemplate,d=safeGetJSON(I,[]),b=c._useExisting&&c._existingContainer,y=d.find(t=>t.id===r.subdomain);if(!(y&&!b&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(y){const t=d.indexOf(y);d.splice(t,1),safeSet(I,JSON.stringify(d))}if(b)r.port=c._existingContainer.primaryPort;else{const t=r.port||c.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await v(c.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
Would you like to use port ${a} instead?`))s.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(m?`Configuring ${p.name} with existing container...`:`Deploying ${p.name}...`,"info",0);try{const t={appId:p.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||p.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};m&&(t.config.useExisting=!0,t.config.existingContainerId=p._existingContainer.id,t.config.existingPort=p._existingContainer.primaryPort,!s.port&&p._existingContainer.primaryPort&&(t.config.port=p._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:s.subdomain,name:p.name,logo:`/assets/${p.id}.png`,containerId:a.containerId,url:a.url,ip:s.ip,appTemplate:p.id,tailscaleOnly:s.tailscaleOnly||!1};l.push(o),safeSet(B,JSON.stringify(l)),window.APPS&&!window.APPS.some(n=>n.id===p.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${p.name} configured with existing container!
URL: ${a.url}`:`${p.name} deployed successfully!
Would you like to use port ${a} instead?`))r.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(b?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const t={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};b&&(t.config.useExisting=!0,t.config.existingContainerId=c._existingContainer.id,t.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(t.config.port=c._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:a.containerId,url:a.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};d.push(o),safeSet(I,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.id===c.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${c.name} configured with existing container!
URL: ${a.url}`:`${c.name} deployed successfully!
URL: ${a.url}`;a.warning&&(i+=`
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete p._useExisting,delete p._existingContainer,a.url&&a.url.startsWith("https://")&&$(a.url,p.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${p.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){w.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${p.name}: ${t.message}`,"error",8e3)}}}async function $(s,p){showNotification(`\u23F3 Generating SSL certificate for ${p}...`,"warning",6e4);let l=0;const m=12,c=async()=>{l++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${p} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<m?setTimeout(c,5e3):showNotification(`\u26A0\uFE0F ${p} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(c,3e3)}function E(){safeGetJSON(B,[]).forEach(p=>{window.APPS.some(l=>l.id===p.id)||window.APPS.push(p)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),z.classList.add("show")}),wireModal(z,document.getElementById("app-selector-cancel"));const I=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{I.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(I.dataset.appTemplate),p=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(c=>{l.push({hostPath:c.value.trim(),containerPath:c.dataset.containerPath})});const m={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:p||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!m.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!p){showNotification("Please enter a media library path for this application","warning");return}I.classList.remove("show"),k(m)}),wireModal(I);const O=document.getElementById("folder-browser-modal"),C=document.getElementById("folder-browser-path"),j=document.getElementById("folder-browser-list"),N=document.getElementById("folder-browser-selected"),R=document.getElementById("folder-browser-selected-list");let u="",v=[],x=null;window.openFolderBrowser=function(s){x=s,v=s.value.split(",").map(p=>p.trim()).filter(p=>p),u="",b(),g(""),O.classList.add("show")};async function g(s){C.textContent=s||"Select a drive...",j.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!l.success){j.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}u=l.path||"",C.textContent=u||"Select a drive...";let m="";l.parent&&l.parent!==l.path&&(m+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,a.url&&a.url.startsWith("https://")&&T(a.url,c.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){f.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${t.message}`,"error",8e3)}}}async function T(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const b=12,y=async()=>{d++;try{const t=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<b?setTimeout(y,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(y,3e3)}function S(){safeGetJSON(I,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{B(),M.classList.add("show")}),wireModal(M,document.getElementById("app-selector-cancel"));const L=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(L.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{d.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const b={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:d.length>0?d:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!b.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}L.classList.remove("show"),k(b)}),wireModal(L);const j=document.getElementById("folder-browser-modal"),C=document.getElementById("folder-browser-path"),N=document.getElementById("folder-browser-list"),R=document.getElementById("folder-browser-selected"),U=document.getElementById("folder-browser-selected-list");let u="",m=[],h=null;window.openFolderBrowser=function(r){h=r,m=r.value.split(",").map(c=>c.trim()).filter(c=>c),u="",H(),g(""),j.classList.add("show")};async function g(r){C.textContent=r||"Select a drive...",N.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const d=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!d.success){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}u=d.path||"",C.textContent=u||"Select a drive...";let b="";d.parent&&d.parent!==d.path&&(b+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span>
</div>`),l.items.length===0&&!l.parent?m+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?m+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(c=>{const t=c.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=v.includes(c.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";m+=`<div class="folder-item" data-path="${escapeHtml(c.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
</div>`),d.items.length===0&&!d.parent?b+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':d.items.length===0?b+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(y=>{const t=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(y.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";b+=`<div class="folder-item" data-path="${escapeHtml(y.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
<span style="font-size: 1.2rem;">${t}</span>
<span style="flex: 1;">${escapeHtml(c.name)}</span>
<span style="flex: 1;">${escapeHtml(y.name)}</span>
${e?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),j.innerHTML=m,j.querySelectorAll(".folder-item").forEach(c=>{c.addEventListener("click",()=>{g(c.dataset.path)}),c.addEventListener("mouseenter",()=>{c.style.background="var(--card-bg)"}),c.addEventListener("mouseleave",()=>{const t=v.includes(c.dataset.path);c.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(p){j.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(p.message)}</div>`}}function b(){if(v.length===0){N.style.display="none";return}N.style.display="block",R.innerHTML=v.map(s=>`
</div>`}),N.innerHTML=b,N.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{g(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const t=m.includes(y.dataset.path);y.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function H(){if(m.length===0){R.style.display="none";return}R.style.display="block",U.innerHTML=m.map(r=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
${escapeHtml(s)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
${escapeHtml(r)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span>
`).join("")}window.removeSelectedFolder=function(s){v=v.filter(p=>p!==s),b(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!v.includes(u)&&(v.push(u),b(),g(u))}),wireModal(O,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{x&&(x.value=v.join(", ")),O.classList.remove("show")}),E()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
`).join("")}window.removeSelectedFolder=function(r){m=m.filter(c=>c!==r),H(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!m.includes(u)&&(m.push(u),H(),g(u))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{h&&(h.value=m.join(", ")),j.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="recipe-next" class="btn-accent-solid">Next</button>
</div>
</div>
</div>`);let w=null,B=null,A=null,S=1,z=!1;const P=document.getElementById("recipe-deploy-modal"),M=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),h=document.getElementById("recipe-next");wireModal(P,M);async function T(){try{const u=await fetch("/api/v1/recipes/templates"),v=await u.json();if(v.success)return w=v.templates,B=v.categories,!0;if(u.status===403)return z=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function f(){try{z=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{z=!1}return z}window.renderRecipeCards=async function(u){await f();let v;if(z&&w?v=w:v=H(),!v||v.length===0)return;const x=document.createElement("div");x.className="app-category-header",x.innerHTML="\u{1F9EA} Recipes",x.style.borderBottomColor="#8e44ad",u.appendChild(x);const g=Array.isArray(v)?v:Object.values(v);g.sort((b,s)=>(s.popularity||0)-(b.popularity||0));for(const b of g){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const p=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${b.componentCount||b.components?.length||"?"} apps</div>`,l=z?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=`
${l}
<div class="app-option-icon">${escapeHtml(b.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(b.name)}</div>
<div class="app-option-desc">${escapeHtml(b.description||"")}</div>
${p}
`,s.onclick=()=>{if(!z){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(b)},u.appendChild(s)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(u){A=u,S=1;const v=document.getElementById("app-selector-modal");v&&v.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,$(),E(),P.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const v=parseInt(u.dataset.step);u.classList.toggle("active",v===S),u.classList.toggle("completed",v<S)});for(let u=1;u<=4;u++){const v=document.getElementById(`recipe-step-${u}`);v&&(v.style.display=u===S?"":"none")}D.style.display=S>1&&S<4?"":"none",S===4?(h.style.display="none",M.textContent="Close"):S===3?(h.textContent="\u{1F680} Deploy",h.style.display="",M.textContent="Cancel"):(h.textContent="Next",h.style.display="",M.textContent="Cancel")}function E(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const v=A.components||[];for(const x of v){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const b=x.required,s=x.internal;g.innerHTML=`
<input type="checkbox" ${b?"checked disabled":"checked"} data-component-id="${x.id}"
</div>`);let f=null,I=null,A=null,E=1,M=!1;const P=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(P,z);async function B(){try{const u=await fetch("/api/v1/recipes/templates"),m=await u.json();if(m.success)return f=m.templates,I=m.categories,!0;if(u.status===403)return M=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function x(){try{M=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{M=!1}return M}window.renderRecipeCards=async function(u){await x();let m;if(M&&f?m=f:m=$(),!m||m.length===0)return;const h=document.createElement("div");h.className="app-category-header",h.innerHTML="\u{1F9EA} Recipes",h.style.borderBottomColor="#8e44ad",u.appendChild(h);const g=Array.isArray(m)?m:Object.values(m);g.sort((H,r)=>(r.popularity||0)-(H.popularity||0));for(const H of g){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${H.componentCount||H.components?.length||"?"} apps</div>`,d=M?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
${d}
<div class="app-option-icon">${escapeHtml(H.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(H.name)}</div>
<div class="app-option-desc">${escapeHtml(H.description||"")}</div>
${c}
`,r.onclick=()=>{if(!M){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(H)},u.appendChild(r)}};function $(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(u){A=u,E=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,T(),S(),P.classList.add("show")}function T(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const m=parseInt(u.dataset.step);u.classList.toggle("active",m===E),u.classList.toggle("completed",m<E)});for(let u=1;u<=4;u++){const m=document.getElementById(`recipe-step-${u}`);m&&(m.style.display=u===E?"":"none")}D.style.display=E>1&&E<4?"":"none",E===4?(v.style.display="none",z.textContent="Close"):E===3?(v.textContent="\u{1F680} Deploy",v.style.display="",z.textContent="Cancel"):(v.textContent="Next",v.style.display="",z.textContent="Cancel")}function S(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const m=A.components||[];for(const h of m){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const H=h.required,r=h.internal;g.innerHTML=`
<input type="checkbox" ${H?"checked disabled":"checked"} data-component-id="${h.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(x.role||x.id)}</div>
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(h.role||h.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);">
${x.templateRef?escapeHtml(x.templateRef):"Built-in"}
${b?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
${h.templateRef?escapeHtml(h.templateRef):"Built-in"}
${H?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</div>
${x.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(x.note)}</div>`:""}
${h.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(h.note)}</div>`:""}
</div>
`,u.appendChild(g)}}function I(){const u=document.getElementById("recipe-volumes-section"),v=document.getElementById("recipe-volume-list"),x=A.sharedVolumes;if(x&&Object.keys(x).length>0){u.style.display="",v.innerHTML="";for(const[g,b]of Object.entries(x)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(b.label||g)}</label>
<input type="text" data-volume-key="${g}" value="${escapeHtml(b.defaultPath||"")}"
placeholder="${escapeHtml(b.defaultPath||"/path")}"
`,u.appendChild(g)}}function L(){const u=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),h=A.sharedVolumes;if(h&&Object.keys(h).length>0){u.style.display="",m.innerHTML="";for(const[g,H]of Object.entries(h)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(H.label||g)}</label>
<input type="text" data-volume-key="${g}" value="${escapeHtml(H.defaultPath||"")}"
placeholder="${escapeHtml(H.defaultPath||"/path")}"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
<div class="form-hint-sm">${escapeHtml(b.description||"")}</div>
`,v.appendChild(s)}}else u.style.display="none"}function O(){const u=document.getElementById("recipe-review-content"),v=C(),x=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),g={};x.forEach(l=>{g[l.dataset.volumeKey]=l.value});const b=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",p=document.getElementById("recipe-tailscale").checked;u.innerHTML=`
<div class="form-hint-sm">${escapeHtml(H.description||"")}</div>
`,m.appendChild(r)}}else u.style.display="none"}function j(){const u=document.getElementById("recipe-review-content"),m=C(),h=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),g={};h.forEach(d=>{g[d.dataset.volumeKey]=d.value});const H=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;u.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(A.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(A.description||"")}</div>
<div style="margin-bottom: 12px;">
<strong>Components (${v.length}):</strong>
<strong>Components (${m.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;">
${v.map(l=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(l.role||l.id)}</strong> ${l.internal?'<span style="color:var(--muted)">(internal)</span>':""}
${m.map(d=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(d.role||d.id)}</strong> ${d.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")}
</div>
</div>
${Object.keys(g).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong>
${Object.entries(g).map(([l,m])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${l}: ${escapeHtml(m)}</div>`).join("")}
${Object.entries(g).map(([d,b])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(b)}</div>`).join("")}
</div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(b)} &bull; IP: ${escapeHtml(s)} ${p?"&bull; Tailscale only":""}
Timezone: ${escapeHtml(H)} &bull; IP: ${escapeHtml(r)} ${c?"&bull; Tailscale only":""}
</div>
${A.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(A.network.name)}</code></div>`:""}
`}function C(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),v=new Set;u.forEach(g=>{g.checked&&v.add(g.dataset.componentId)});const x=A.components||[];return x.filter(g=>g.required).forEach(g=>v.add(g.id)),x.filter(g=>v.has(g.id))}async function j(){const u=document.getElementById("recipe-progress-list"),v=document.getElementById("recipe-deploy-result");v.style.display="none",u.innerHTML="";const x=C();for(const p of x){const l=document.createElement("div");l.id=`recipe-progress-${p.id}`,l.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",l.innerHTML=`
`}function C(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;u.forEach(g=>{g.checked&&m.add(g.dataset.componentId)});const h=A.components||[];return h.filter(g=>g.required).forEach(g=>m.add(g.id)),h.filter(g=>m.has(g.id))}async function N(){const u=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",u.innerHTML="";const h=C();for(const c of h){const d=document.createElement("div");d.id=`recipe-progress-${c.id}`,d.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",d.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(p.role||p.id)}</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,u.appendChild(l)}const g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),b={};g.forEach(p=>{b[p.dataset.volumeKey]=p.value});const s={selectedComponents:x.map(p=>p.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:b},componentOverrides:{}};for(const p of x)N(p.id,"deploying","Deploying...");try{const l=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:s})})).json();if(l.success){for(const m of l.deployed||[])N(m.id,"success",m.url?`Running \u2192 ${m.url}`:"Running");for(const m of l.errors||[])N(m.componentId,"error",m.error);v.style.display="",v.innerHTML=`
`,u.appendChild(d)}const g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),H={};g.forEach(c=>{H[c.dataset.volumeKey]=c.value});const r={selectedComponents:h.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:H},componentOverrides:{}};for(const c of h)R(c.id,"deploying","Deploying...");try{const d=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:r})})).json();if(d.success){for(const b of d.deployed||[])R(b.id,"success",b.url?`Running \u2192 ${b.url}`:"Running");for(const b of d.errors||[])R(b.componentId,"error",b.error);m.style.display="",m.innerHTML=`
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(l.message||"Deployed!")}</div>
${l.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(d.message||"Deployed!")}</div>
${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${l.setupInstructions.map(m=>`<li>${escapeHtml(m)}</li>`).join("")}</ul>
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(b=>`<li>${escapeHtml(b)}</li>`).join("")}</ul>
</div>`:""}
</div>
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else v.style.display="",v.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(l.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(p){v.style.display="",v.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(p.message)}
</div>`}}function N(u,v,x){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const b=g.querySelector(".recipe-progress-icon"),s=g.querySelector(".recipe-progress-status");v==="deploying"?(b.textContent="\u23F3",s.style.color="var(--accent)"):v==="success"?(b.textContent="\u2705",s.style.color="var(--ok-fg)"):v==="error"&&(b.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=x}h.addEventListener("click",()=>{if(S===3){S=4,$(),j();return}S<3&&(S++,$(),S===2&&I(),S===3&&O())}),D.addEventListener("click",()=>{S>1&&S<4&&(S--,$())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const v={};u.forEach(x=>{const g=x.dataset.recipeId;v[g]||(v[g]=[]),v[g].push(x)});for(const[x,g]of Object.entries(v))g.length<2||g.forEach((b,s)=>{if(b.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let p=b.querySelector(".recipe-group-label");p||(p=document.createElement("div"),p.className="recipe-group-label",p.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",p.textContent=x.replace(/-/g," "),b.style.position="relative",b.appendChild(p))}})},window.manageRecipe=async function(u,v){const x=`/api/v1/recipes/${u}/${v}`,g=v==="remove"?"DELETE":"POST",b=v==="remove"?`/api/v1/recipes/${u}`:x;if(!(v==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const p=await(await secureFetch(b,{method:g})).json();p.success?(showNotification(`Recipe ${v}: ${p.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${v} failed: ${p.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const R=document.createElement("style");R.textContent=`
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(d.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(c.message)}
</div>`}}function R(u,m,h){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const H=g.querySelector(".recipe-progress-icon"),r=g.querySelector(".recipe-progress-status");m==="deploying"?(H.textContent="\u23F3",r.style.color="var(--accent)"):m==="success"?(H.textContent="\u2705",r.style.color="var(--ok-fg)"):m==="error"&&(H.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=h}v.addEventListener("click",()=>{if(E===3){E=4,T(),N();return}E<3&&(E++,T(),E===2&&L(),E===3&&j())}),D.addEventListener("click",()=>{E>1&&E<4&&(E--,T())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const m={};u.forEach(h=>{const g=h.dataset.recipeId;m[g]||(m[g]=[]),m[g].push(h)});for(const[h,g]of Object.entries(m))g.length<2||g.forEach((H,r)=>{if(H.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=H.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=h.replace(/-/g," "),H.style.position="relative",H.appendChild(c))}})},window.manageRecipe=async function(u,m){const h=`/api/v1/recipes/${u}/${m}`,g=m==="remove"?"DELETE":"POST",H=m==="remove"?`/api/v1/recipes/${u}`:h;if(!(m==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(H,{method:g})).json();c.success?(showNotification(`Recipe ${m}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const U=document.createElement("style");U.textContent=`
.recipe-step {
flex: 1;
text-align: center;
@@ -550,55 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel {
min-height: 180px;
}
`,document.head.appendChild(R),f()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const w=document.getElementById("reload-caddy-top"),B=w.textContent;try{w.textContent="\u23F3 Reloading...",w.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),S=await A.json();if(A.ok&&S.success)w.textContent="\u2705 Reloaded!",setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3);else throw new Error(S.error||"Reload failed")}catch(A){w.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>\u{1F4CB} Error Logs</h3>
<p class="modal-subtitle">
Errors and warnings from the DashCaddy API. Click a row to see the full stack trace.
</p>
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
<label class="text-muted-sm">Level:</label>
<select id="error-log-level" 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</option>
<option value="ERR">Errors</option>
<option value="WARN">Warnings</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
</select>
<label class="text-muted-sm">Context:</label>
<select id="error-log-context" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem; max-width: 220px;">
<option value="">All</option>
</select>
<label class="text-muted-sm" style="margin-left: 8px;">Search:</label>
<input id="error-log-search" type="search" placeholder="message / stack / ip" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem; min-width: 180px;">
<label class="text-muted-sm" style="margin-left: 8px;">Since:</label>
<input id="error-log-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="error-log-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="error-log-refresh" class="btn-sm">\u{1F504} Refresh</button>
<span style="flex: 1;"></span>
<button id="error-log-clear" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">\u{1F5D1}\uFE0F Clear Log</button>
</div>
<div id="error-log-container" class="scroll-container">
<div class="panel-empty"><span class="brand-spinner"></span> Loading error logs...</div>
</div>
<div style="margin-top: 12px; text-align: center;">
<button id="error-log-load-more" style="display: none; padding: 6px 16px; font-size: 0.8rem;">Load More</button>
</div>
<div style="margin-top: 8px; font-size: 0.78rem; color: var(--muted); text-align: right;">
<span id="error-log-total"></span>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="error-log-close">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("error-log-modal"),B=document.getElementById("view-error-logs"),A=document.getElementById("error-log-refresh"),S=document.getElementById("error-log-clear"),z=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),M=document.getElementById("error-log-context"),D=document.getElementById("error-log-search"),h=document.getElementById("error-log-since"),T=document.getElementById("error-log-until"),f=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),k=document.getElementById("error-log-total"),$=50;let E=0,I=null,O=0,C=[];function j(b){if(!b)return null;const s=new Date(b);return isNaN(s.getTime())?null:s.toISOString()}async function N(){try{const b=await fetch("/api/v1/error-logs/contexts");if(!b.ok)return;const s=await b.json();if(!s.success||!Array.isArray(s.contexts))return;C=s.contexts;const p=M.value;M.innerHTML='<option value="">All</option>';for(const l of s.contexts){const m=document.createElement("option");m.value=l.name,m.textContent=`${l.name} (${l.count})`,M.appendChild(m)}p&&s.contexts.some(l=>l.name===p)&&(M.value=p)}catch{}}function R(){const b=new URLSearchParams;b.set("limit",String($)),b.set("offset",String(E)),P.value&&b.set("level",P.value),M.value&&b.set("context",M.value);const s=j(h.value),p=j(T.value);s&&b.set("since",s),p&&b.set("until",p);const l=(D.value||"").trim();return l&&b.set("search",l),b}async function u(b){try{b?(I&&I.abort(),I=new AbortController):(I&&I.abort(),I=new AbortController,E=0,O++,f.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const s=O,p=R(),l=await fetch("/api/v1/error-logs?"+p.toString(),{signal:I.signal});if(!l.ok){f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,H.style.display="none",k.textContent="";return}const m=await l.json();if(!m.success){f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.error||"unknown")}</div>`,H.style.display="none",k.textContent="";return}if(!b&&s!==O)return;const c=Array.isArray(m.logs)?m.logs:[];if(c.length===0&&!b){const e=m.filters&&(m.filters.level||m.filters.context||m.filters.search||m.filters.since||m.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";f.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,H.style.display="none",k.textContent=m.total?`${m.total} total`:"";return}let t="";b||(t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',t+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',t+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',t+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',t+='<th style="padding: 6px; text-align: left;">Message</th>',t+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',t+="</tr>");for(const e of c){const a=(e.level||"?").toUpperCase(),o=a==="ERR"?"var(--bad-fg)":a==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",i=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",n=e.context||"\u2014",r=(e.error||"").split(`
`)[0],d=e.request&&e.request.ip||"";t+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',t+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(i)}</td>`,t+=`<td style="padding: 6px;"><span style="color: ${o}; font-weight: 600;">${escapeHtml(a)}</span></td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(n)}</td>`,t+=`<td style="padding: 6px;">${escapeHtml(r)}</td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(d)}</td>`,t+="</tr>",e.detail&&(t+=`<tr class="error-log-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; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!b)t+="</table>",f.innerHTML=t;else{const e=f.querySelector("table");e&&e.insertAdjacentHTML("beforeend",t)}E+=c.length,H.style.display=m.hasMore?"":"none",k.textContent=`${m.total} total${m.hasMore?" (showing "+E+")":""}`,f.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const a=e.nextElementSibling;a&&a.classList.contains("error-log-detail")&&(a.style.display=a.style.display==="none"?"":"none")}))})}catch(s){if(s&&s.name==="AbortError")return;f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`,k.textContent=""}}async function v(){if(confirm("Clear the entire error log? This cannot be undone."))try{const s=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();s.success?(await N(),u(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(s.error||"Clear failed"),"error",4e3)}catch(b){showNotification("\u274C "+b.message,"error",4e3)}}let x;function g(){P?.addEventListener("change",()=>u(!1)),M?.addEventListener("change",()=>u(!1)),D?.addEventListener("input",()=>{clearTimeout(x),x=setTimeout(()=>u(!1),250)});let b;[h,T].forEach(s=>{s?.addEventListener("change",()=>{clearTimeout(b),b=setTimeout(()=>u(!1),250)})}),A?.addEventListener("click",()=>u(!1)),H?.addEventListener("click",()=>u(!0)),S?.addEventListener("click",v),wireModal(w,z)}B?.addEventListener("click",async()=>{w?.classList.add("show"),await N(),u(!1)}),g()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
`,document.head.appendChild(U),x()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const f=document.getElementById("reload-caddy-top"),I=f.textContent;try{f.textContent="\u23F3 Reloading...",f.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),E=await A.json();if(A.ok&&E.success)f.textContent="\u2705 Reloaded!",setTimeout(()=>{f.textContent=I,f.disabled=!1},2e3);else throw new Error(E.error||"Reload failed")}catch(A){f.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{f.textContent=I,f.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} 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">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</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 f=document.getElementById("error-log-modal"),I=document.getElementById("error-log-content"),A=document.getElementById("view-error-logs"),E=document.getElementById("error-log-refresh"),M=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function z(){I.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const B=await(await fetch("/api/v1/error-logs")).json();B.success&&B.logs?B.logs.length===0?I.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':I.innerHTML=B.logs.map(x=>`
<div class="log-entry error">
<span class="log-timestamp">${new Date(x.timestamp).toLocaleString()}</span>
<span class="log-level">ERROR</span>
<div class="log-message">
<strong>${escapeHtml(x.context)}</strong>: ${escapeHtml(x.error)}
${x.details?`<br><small style="opacity: 0.7;">${escapeHtml(x.details)}</small>`:""}
</div>
</div>
`).join(""):I.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){I.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function D(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}A?.addEventListener("click",()=>{f.classList.add("show"),z()}),E?.addEventListener("click",z),M?.addEventListener("click",D),wireModal(f,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
@@ -648,57 +609,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("container-logs-modal"),B=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),S=document.getElementById("cl-log-search"),z=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),M=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),h=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),f=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),E=document.getElementById("cl-line-count"),I=document.getElementById("cl-filter-count"),O=document.getElementById("cl-image"),C=document.getElementById("cl-status"),j=document.getElementById("cl-created");let N=null,R=[],u=[],v=null,x=!1,g=null;function b(r){if(!r)return"-";const d=new Date(r);return isNaN(d.getTime())?r:d.toLocaleString()}function s(r){if(!r)return"";const d=document.createElement("div");return d.textContent=r,d.innerHTML}function p(r,d){const y=r.stream==="stderr"?"log-stderr":"log-stdout",L=r.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${y}" data-index="${d}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${d+1}</span>
<span class="log-stream-icon" style="color: ${r.stream==="stderr"?"#f59e0b":"#22c55e"};">${L}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(r.text)}</span>
</div>`);const f=document.getElementById("container-logs-modal"),I=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),E=document.getElementById("cl-log-search"),M=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),B=document.getElementById("cl-close"),x=document.getElementById("cl-close-btn"),$=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),T=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),L=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),C=document.getElementById("cl-status"),N=document.getElementById("cl-created");let R=null,U=[],u=[],m=null,h=!1,g=null;function H(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function r(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const p=s.stream==="stderr"?"log-stderr":"log-stdout",w=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${p}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${w}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(s.text)}</span>
</div>
`}function l(r,d=""){if(!r||r.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',E.textContent="0 lines",I.textContent="0 filtered";return}if(R=r,u=d?r.filter(y=>y.text&&y.text.toLowerCase().includes(d.toLowerCase())):r,E.textContent=`${r.length} lines`,I.textContent=d?`${u.length} of ${r.length} shown`:`${r.length} shown`,u.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(d)}"</div>`;return}A.innerHTML=u.map((y,L)=>p(y,L)).join(""),A.scrollTop=A.scrollHeight}async function m(){try{const d=(await getJSON("/api/v1/logs/containers")).containers||[],y=B.value;B.innerHTML='<option value="">Select a container...</option>',d.forEach(L=>{const U=document.createElement("option");U.value=L.id,U.textContent=`${L.name} (${L.image.split(":")[0]}) - ${L.status}`,U.dataset.name=L.name,U.dataset.image=L.image,U.dataset.status=L.status,U.dataset.created=L.created,B.appendChild(U)}),y&&B.querySelector(`option[value="${y}"]`)&&(B.value=y,c(y))}catch(r){console.error("Failed to load containers:",r)}}function c(r){const d=B.querySelector(`option[value="${r}"]`);d&&(O.textContent=d.dataset.image||"-",C.textContent=d.dataset.status||"-",C.style.color=d.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",j.textContent=b(d.dataset.created))}async function t(){const r=B.value;if(!r){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),N=r,c(r);const d=z.value,y=S.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const L=`/api/v1/logs/container/${r}${d!=="all"?`?tail=${d}`:""}`,U=await getJSON(L);U.logs&&U.logs.length>0?l(U.logs,y):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',E.textContent="0 lines",I.textContent="0 filtered")}catch(L){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(L.message)}</div>`}}function e(){const r=B.value;if(!r)return;a(),x=!0,M.textContent="\u23F9 Stop",H.style.display="flex",k.textContent="\u{1F7E2}",$.textContent="Connecting...";const d=`/api/v1/logs/stream/${r}`;v=new EventSource(d),v.onopen=()=>{k.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},v.onmessage=y=>{try{const L=JSON.parse(y.data);if(L.error){k.textContent="\u{1F534}",$.textContent=`Error: ${L.error}`;return}R.push(L),u.push(L),E.textContent=`${R.length} lines`,I.textContent=`${u.length} shown`;const U=S.value.trim();if(!U||L.text&&L.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=p(L,u.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(L){console.error("Error parsing log:",L)}},v.onerror=()=>{k.textContent="\u{1F534}",$.textContent="Disconnected",x=!1,M.textContent="\u25B6 Stream"},w._eventSource=v}function a(){v&&(v.close(),v=null),w._eventSource&&(w._eventSource.close(),w._eventSource=null),x=!1,M.textContent="\u25B6 Stream",H.style.display="none"}function o(){if(!R||R.length===0){showNotification("No logs to download","error");return}const r=B.querySelector(`option[value="${N}"]`)?.dataset.name||N,d=new Date().toISOString().replace(/[:.]/g,"-"),y=`${r}-logs-${d}.txt`,L=R.map(_=>{const J=_.timestamp||"",X=_.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${_.text}`}).join(`
`),U=new Blob([L],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=y,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${R.length} log lines`,"success")}B?.addEventListener("change",()=>{t()}),z?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),M?.addEventListener("click",()=>{x?a():e()}),D?.addEventListener("click",()=>{o()}),h?.addEventListener("click",()=>{S.value="",l(R,"")}),S?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{l(R,S.value.trim())},300)}),S?.addEventListener("keydown",r=>{r.key==="Escape"&&(S.value="",l(R,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{w.classList.add("show"),m()});function n(){a(),w.classList.remove("show")}T?.addEventListener("click",n),f?.addEventListener("click",n),document.addEventListener("keydown",r=>{r.key==="Escape"&&w.classList.contains("show")&&n()}),w.addEventListener("click",r=>{r.target===w&&n()}),window.openContainerLogsModal=function(r,d){w.classList.add("show"),m().then(()=>{const y=Array.from(B.options).find(L=>L.value===r||L.dataset.name===d);y?(B.value=y.value,c(y.value),t()):r?(N=r,O.textContent=d||r,C.textContent="-",j.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const w=[{unit:"caddy",label:"Caddy (reverse proxy)"},{unit:"dashcaddy-api",label:"DashCaddy API (host systemd unit, not this container)"},{unit:"docker",label:"Docker daemon"},{unit:"ssh",label:"SSH server"},{unit:"systemd-journald",label:"systemd-journald"},{unit:"tailscaled",label:"Tailscale"},{unit:"networkd-dispatcher",label:"Networkd dispatcher"}];injectModal("journald-modal",`
<div id="journald-modal" class="weather-modal" style="z-index: 1002;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
<h3 style="margin: 0;">\u{1F6F0}\uFE0F Host Logs (journald)</h3>
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">
Stream host service logs from <code>journalctl</code> (read-only mount). Docker container logs are still in the <em>Container Logs</em> modal.
</p>
</div>
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end;">
<select id="jd-unit-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 220px;"></select>
<input type="text" id="jd-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 160px;" />
<input type="number" id="jd-tail" min="1" max="5000" value="200" title="Lines to load (historical view)" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 90px;" />
<button id="jd-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u{1F504} Load tail</button>
<button id="jd-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u25B6 Stream</button>
<button id="jd-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">\u2715</button>
<button id="jd-close" class="close-btn" style="padding: 6px 10px;">\u2715</button>
</div>
</div>
<div id="jd-meta" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem; flex-wrap: wrap;">
<span><strong>Source:</strong> <span id="jd-source">journald</span></span>
<span><strong>Unit:</strong> <span id="jd-unit-display">-</span></span>
<span><strong>Stream:</strong> <span id="jd-stream-state">disconnected</span></span>
</div>
<div id="jd-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a unit and click <em>Load tail</em> or <em>Stream</em>.</div>
</div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
<span id="jd-line-count">0 lines</span>
<span>|</span>
<span id="jd-filter-count">0 shown</span>
<span>|</span>
<span id="jd-overflow" style="display: none; color: var(--warn-fg, #fbbf24);">\u26A0 stream overflow \u2014 re-load with narrower window</span>
</div>
<button id="jd-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>
`);const B=document.getElementById("journald-modal"),A=document.getElementById("jd-unit-select"),S=document.getElementById("jd-search"),z=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),M=document.getElementById("jd-stream"),D=document.getElementById("jd-clear-search"),h=document.getElementById("jd-close"),T=document.getElementById("jd-close-btn"),f=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),k=document.getElementById("jd-filter-count"),$=document.getElementById("jd-overflow"),E=document.getElementById("jd-unit-display"),I=document.getElementById("jd-stream-state");let O=!1,C=[],j=!1,N=null,R=null;function u(c){const t=document.createElement("div");return t.textContent=String(c),t.innerHTML}function v(c){O=c,A.innerHTML="",w.forEach(t=>{const e=document.createElement("option");e.value=t.unit,e.textContent=t.label+" ("+t.unit+")",A.appendChild(e)}),A.disabled=!c,c?(P.disabled=!1,M.disabled=!1):(f.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>',P.disabled=!0,M.disabled=!0)}async function x(){try{const c=await fetch("/api/v1/logs/journal/units");if(!c.ok){v(!1);return}const t=await c.json();v(!!t.available)}catch{v(!1)}}function g(){const c=(S.value||"").trim().toLowerCase(),t=c?C.filter(e=>(e.textContent||"").toLowerCase().includes(c)):C;if(t.length===0)f.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(c?` matching &quot;${u(c)}&quot;`:"")+"</div>";else{const e=t.map(o=>{const i=o.timestamp?u(o.timestamp):"\u2014",n=u(o.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${i}</span>${n}</div>`}).join("");f.innerHTML=e,f.scrollHeight-f.scrollTop-f.clientHeight<80&&(f.scrollTop=f.scrollHeight)}H.textContent=`${C.length} entries`,k.textContent=c?`${t.length} of ${C.length} shown`:`${C.length} shown`}async function b(){if(!O)return;p();const c=A.value;if(!c)return;const t=Math.max(1,Math.min(5e3,Number(z.value)||200)),e=(S.value||"").trim();f.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const a=new URL("/api/v1/logs/journal",window.location.origin);a.searchParams.set("unit",c),a.searchParams.set("tail",String(t)),e&&a.searchParams.set("search",e);const o=await fetch(a.toString()),i=await o.json();if(!o.ok||!i.success){f.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+u(i&&i.error||"HTTP "+o.status)+"</div>";return}E.textContent=c,C=(i.entries||[]).map(n=>({timestamp:n.timestamp,unit:n.unit,textContent:n.text||""})),$.style.display="none",g()}catch(a){f.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+u(a.message)+"</div>"}}function s(){if(!O)return;p();const c=A.value;if(!c)return;const t=(S.value||"").trim();E.textContent=c,M.textContent="\u23F8 Stop",M.classList.add("streaming"),I.textContent="streaming",I.style.color="var(--ok-fg, #4ade80)",C=[],g(),$.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",c),t&&e.searchParams.set("search",t),N=new EventSource(e.toString()),N.onmessage=a=>{try{const o=JSON.parse(a.data);if(o.error){/stream (exceeded|line cap)/.test(o.error)&&($.style.display="",p()),f.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+u(o.error)+"</div>",f.scrollTop=f.scrollHeight;return}C.push({timestamp:o.timestamp,unit:o.unit||c,textContent:o.text||""}),C.length>5e3&&(C=C.slice(C.length-5e3),$.style.display=""),g()}catch{}},N.onerror=()=>{},j=!0}function p(){if(j=!1,N){try{N.close()}catch{}N=null}M.textContent="\u25B6 Stream",M.classList.remove("streaming"),I.textContent="disconnected",I.style.color="var(--muted)"}function l(){p(),B.classList.remove("show")}P.addEventListener("click",b),M.addEventListener("click",()=>j?p():s()),D.addEventListener("click",()=>{S.value="",g()}),S.addEventListener("input",()=>{clearTimeout(R),R=setTimeout(g,200)}),S.addEventListener("keydown",c=>{c.key==="Escape"&&(S.value="",g())}),h.addEventListener("click",l),T.addEventListener("click",l),B.addEventListener("click",c=>{c.target===B&&l()}),document.addEventListener("keydown",c=>{c.key==="Escape"&&B.classList.contains("show")&&l()}),A.addEventListener("change",()=>{C.length>0&&b()});function m(){B.classList.add("show"),x()}window.openJournaldModal=m,document.getElementById("view-journald-logs")?.addEventListener("click",m)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
`}function d(s,l=""){if(!s||s.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",L.textContent="0 filtered";return}if(U=s,u=l?s.filter(p=>p.text&&p.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,L.textContent=l?`${u.length} of ${s.length} shown`:`${s.length} shown`,u.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}A.innerHTML=u.map((p,w)=>c(p,w)).join(""),A.scrollTop=A.scrollHeight}async function b(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],p=I.value;I.innerHTML='<option value="">Select a container...</option>',l.forEach(w=>{const O=document.createElement("option");O.value=w.id,O.textContent=`${w.name} (${w.image.split(":")[0]}) - ${w.status}`,O.dataset.name=w.name,O.dataset.image=w.image,O.dataset.status=w.status,O.dataset.created=w.created,I.appendChild(O)}),p&&I.querySelector(`option[value="${p}"]`)&&(I.value=p,y(p))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=I.querySelector(`option[value="${s}"]`);l&&(j.textContent=l.dataset.image||"-",C.textContent=l.dataset.status||"-",C.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",N.textContent=H(l.dataset.created))}async function t(){const s=I.value;if(!s){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),R=s,y(s);const l=M.value,p=E.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const w=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,O=await getJSON(w);O.logs&&O.logs.length>0?d(O.logs,p):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',S.textContent="0 lines",L.textContent="0 filtered")}catch(w){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(w.message)}</div>`}}function e(){const s=I.value;if(!s)return;a(),h=!0,z.textContent="\u23F9 Stop",$.style.display="flex",k.textContent="\u{1F7E2}",T.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;m=new EventSource(l),m.onopen=()=>{k.textContent="\u{1F7E2}",T.textContent="Connected - streaming logs"},m.onmessage=p=>{try{const w=JSON.parse(p.data);if(w.error){k.textContent="\u{1F534}",T.textContent=`Error: ${w.error}`;return}U.push(w),u.push(w),S.textContent=`${U.length} lines`,L.textContent=`${u.length} shown`;const O=E.value.trim();if(!O||w.text&&w.text.toLowerCase().includes(O.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(w,u.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(w){console.error("Error parsing log:",w)}},m.onerror=()=>{k.textContent="\u{1F534}",T.textContent="Disconnected",h=!1,z.textContent="\u25B6 Stream"},f._eventSource=m}function a(){m&&(m.close(),m=null),f._eventSource&&(f._eventSource.close(),f._eventSource=null),h=!1,z.textContent="\u25B6 Stream",$.style.display="none"}function o(){if(!U||U.length===0){showNotification("No logs to download","error");return}const s=I.querySelector(`option[value="${R}"]`)?.dataset.name||R,l=new Date().toISOString().replace(/[:.]/g,"-"),p=`${s}-logs-${l}.txt`,w=U.map(q=>{const J=q.timestamp||"",X=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${q.text}`}).join(`
`),O=new Blob([w],{type:"text/plain"}),_=URL.createObjectURL(O),F=document.createElement("a");F.href=_,F.download=p,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${U.length} log lines`,"success")}I?.addEventListener("change",()=>{t()}),M?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{h?a():e()}),D?.addEventListener("click",()=>{o()}),v?.addEventListener("click",()=>{E.value="",d(U,"")}),E?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{d(U,E.value.trim())},300)}),E?.addEventListener("keydown",s=>{s.key==="Escape"&&(E.value="",d(U,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{f.classList.add("show"),b()});function n(){a(),f.classList.remove("show")}B?.addEventListener("click",n),x?.addEventListener("click",n),document.addEventListener("keydown",s=>{s.key==="Escape"&&f.classList.contains("show")&&n()}),f.addEventListener("click",s=>{s.target===f&&n()}),window.openContainerLogsModal=function(s,l){f.classList.add("show"),b().then(()=>{const p=Array.from(I.options).find(w=>w.value===s||w.dataset.name===l);p?(I.value=p.value,y(p.value),t()):s?(R=s,j.textContent=l||s,C.textContent="-",N.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>\u{1F4BE} Container Snapshots</h3>
<p class="modal-subtitle">
@@ -752,7 +670,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("snapshot-modal"),B=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),S=document.getElementById("snapshot-container-select"),z=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),M=document.getElementById("snapshot-create-status");let D=null;async function h(){try{const E=await(await fetch("/api/v1/containers")).json();if(!E.success||!E.containers)return;S.innerHTML='<option value="">-- Select a container --</option>';for(const I of E.containers){const O=document.createElement("option");O.value=I.id,O.textContent=`${I.name||I.id} (${I.image||"unknown"})`,O.dataset.name=I.name,O.dataset.image=I.image,O.dataset.status=I.status,O.dataset.created=I.created,S.appendChild(O)}}catch($){console.error("Failed to load containers:",$)}}function T($){if(!$||!$.value){z.style.display="none",D=null;return}D=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),z.style.display=""}async function f(){if(!D){M.textContent="Please select a container first",M.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){M.textContent="Please enter a snapshot name",M.style.color="var(--bad-fg)";return}const E=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",M.textContent="";try{const O=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:E})})).json();O.success?(M.textContent=`\u2713 Snapshot "${$}" created successfully`,M.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(M.textContent=`\u2717 Failed: ${O.error||"Unknown error"}`,M.style.color="var(--bad-fg)")}catch(I){M.textContent=`\u2717 Error: ${I.message}`,M.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function H(){w.classList.add("show"),h()}function k(){w.classList.remove("show"),z.style.display="none",D=null,S.selectedIndex=0}B?.addEventListener("click",H),A?.addEventListener("click",k),wireModal(w,A),S?.addEventListener("change",$=>{const E=S.options[S.selectedIndex];T(E)}),P?.addEventListener("click",f),w?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{w.querySelectorAll(".panel-tab").forEach(E=>E.classList.remove("active")),w.querySelectorAll(".panel-section").forEach(E=>E.classList.remove("active")),$.classList.add("active"),w.querySelector(`#${$.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
</div>`);const f=document.getElementById("snapshot-modal"),I=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),E=document.getElementById("snapshot-container-select"),M=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let D=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;E.innerHTML='<option value="">-- Select a container --</option>';for(const L of S.containers){const j=document.createElement("option");j.value=L.id,j.textContent=`${L.name||L.id} (${L.image||"unknown"})`,j.dataset.name=L.name,j.dataset.image=L.image,j.dataset.status=L.status,j.dataset.created=L.created,E.appendChild(j)}}catch(T){console.error("Failed to load containers:",T)}}function B(T){if(!T||!T.value){M.style.display="none",D=null;return}D=T.value,document.getElementById("snapshot-image").textContent=T.dataset.image||"-",document.getElementById("snapshot-status").textContent=T.dataset.status||"-",document.getElementById("snapshot-created").textContent=T.dataset.created?new Date(T.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=T.value.substring(0,12),M.style.display=""}async function x(){if(!D){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const T=document.getElementById("snapshot-name").value.trim();if(!T){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,leaveRunning:S})})).json();j.success?(z.textContent=`\u2713 Snapshot "${T}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(L){z.textContent=`\u2717 Error: ${L.message}`,z.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function $(){f.classList.add("show"),v()}function k(){f.classList.remove("show"),M.style.display="none",D=null,E.selectedIndex=0}I?.addEventListener("click",$),A?.addEventListener("click",k),wireModal(f,A),E?.addEventListener("change",T=>{const S=E.options[E.selectedIndex];B(S)}),P?.addEventListener("click",x),f?.querySelectorAll(".panel-tab").forEach(T=>{T.addEventListener("click",()=>{f.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),f.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),T.classList.add("active"),f.querySelector(`#${T.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -831,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("arr-setup-modal"),B=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),S=document.getElementById("smart-connect-btn"),z=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),M=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),h=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),f=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let E=null;const I={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},O={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function C(g){z.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",M.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function j(g){const b={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},s=b[g]||b.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function N(){C("detect"),h.style.display="none";try{if(E=await(await fetch("/api/v1/arr/smart-detect")).json(),!E.success){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(E.error)}</div>`,h.style.display="block";return}let b='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[p,l]of Object.entries(E.services)){const m=I[p]||"\u{1F4E6}",c=O[p]||p,t=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",a=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";b+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${m}</span>
</div>`);const f=document.getElementById("arr-setup-modal"),I=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),E=document.getElementById("smart-connect-btn"),M=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),B=document.getElementById("smart-credential-inputs"),x=document.getElementById("smart-progress-steps"),$=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),T=document.getElementById("smart-retry-btn");let S=null;const L={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function C(g){M.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",z.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function N(g){const H={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},r=H[g]||H.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function R(){C("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,v.style.display="block";return}let H='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(S.services)){const b=L[c]||"\u{1F4E6}",y=j[c]||c,t=d.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(d.source)}</span>`:"",e=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",a=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";H+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${b}</span>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${c}</div>
<div style="font-weight: 500; font-size: 0.9rem;">${y}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${t} ${e} ${a}
</div>
</div>
${j(l.status)}
</div>`}b+="</div>";const s=E.summary;b+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected &middot;
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` &middot; <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""}
</div>`,h.innerHTML=b,h.style.display="block",R(E),setTimeout(()=>{C("credentials")},800)}catch(g){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,h.style.display="block"}}function R(g){let b="";const s=g.services,p=["radarr","sonarr","prowlarr"];for(const c of p){const t=s[c];if(!t||t.status==="not_found"&&!t.url)continue;const e=I[c],a=O[c],o=t.status==="connected";b+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
${N(d.status)}
</div>`}H+="</div>";const r=S.summary;H+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected &middot;
${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` &middot; <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
</div>`,v.innerHTML=H,v.style.display="block",U(S),setTimeout(()=>{C("credentials")},800)}catch(g){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,v.style.display="block"}}function U(g){let H="";const r=g.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const t=r[y];if(!t||t.status==="not_found"&&!t.url)continue;const e=L[y],a=j[y],o=t.status==="connected";H+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${e}</span>
<span style="font-weight: 500;">${a}</span>
<span id="smart-${c}-status" style="margin-left: auto; font-size: 0.75rem;">
<span id="smart-${y}-status" style="margin-left: auto; font-size: 0.75rem;">
${o?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
<input type="text" id="smart-${c}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${c}/"
<input type="text" id="smart-${y}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${y}/"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${c}-key" placeholder="${o?"(saved)":"Paste API key"}"
<input type="password" id="smart-${y}-key" placeholder="${o?"(saved)":"Paste API key"}"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
</div>
<button onclick="smartTestConnection('${c}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const l=s.plex;if(l){const c=l.status==="connected";b+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<button onclick="smartTestConnection('${y}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const d=r.plex;if(d){const y=d.status==="connected";H+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span>
${j(l.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(l.source||"")}</span>
${N(d.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span>
</div>
</div>`}const m=s.seerr;if(m){const c=m.status==="connected";let t="";if(m.configuredServices){const e=m.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
</div>`}const b=r.seerr;if(b){const y=b.status==="connected";let t="";if(b.configuredServices){const e=b.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${e.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}b+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"};">
</div>`}H+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span>
${j(m.status)}
${N(b.status)}
</div>
${t}
</div>`}T.innerHTML=b}window.smartTestConnection=async function(g){const b=document.getElementById(`smart-${g}-url`),s=document.getElementById(`smart-${g}-key`),p=document.getElementById(`smart-${g}-status`),l=b?.value.trim(),m=s?.value.trim();if(!l||!m){p.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}p.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,url:l,apiKey:m})})).json();t.success?p.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(c){p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(c.message)}</span>`}};async function u(){C("progress"),f.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const g={};for(const s of["radarr","sonarr","prowlarr"]){const p=document.getElementById(`smart-${s}-url`)?.value.trim(),l=document.getElementById(`smart-${s}-key`)?.value.trim();l&&p?g[s]={apiKey:l,url:p}:l&&(g[s]={apiKey:l})}const b={services:Object.keys(g).length>0?g:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const p=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)})).json();let l="";for(const m of p.steps||[]){const c=m.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=m.status==="success"?"var(--muted)":"var(--bad-fg)";l+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${c}
<span>${escapeHtml(m.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(m.details||"")}</span>
</div>`}f.innerHTML=l,setTimeout(()=>v(p),500)}catch(s){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function v(g){C("results");const b=g.summary||{},s=b.failed===0&&b.succeeded>0,p=s?"var(--ok-fg)":"#f39c12",l=s?"&#10003;":"&#9888;",m=s?"All Connected!":`${escapeHtml(String(b.succeeded))}/${escapeHtml(String(b.totalSteps))} Steps Succeeded`;let c=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${p} 12%, transparent); border-radius: 10px; border: 1px solid ${p}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${p};">${l}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${p};">${m}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(b.succeeded))} succeeded, ${escapeHtml(String(b.failed))} failed</div>
</div>`;c+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of g.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';c+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
</div>`}B.innerHTML=H}window.smartTestConnection=async function(g){const H=document.getElementById(`smart-${g}-url`),r=document.getElementById(`smart-${g}-key`),c=document.getElementById(`smart-${g}-status`),d=H?.value.trim(),b=r?.value.trim();if(!d||!b){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,url:d,apiKey:b})})).json();t.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(y){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(y.message)}</span>`}};async function u(){C("progress"),x.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const g={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),d=document.getElementById(`smart-${r}-key`)?.value.trim();d&&c?g[r]={apiKey:d,url:c}:d&&(g[r]={apiKey:d})}const H={services:Object.keys(g).length>0?g:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(H)})).json();let d="";for(const b of c.steps||[]){const y=b.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=b.status==="success"?"var(--muted)":"var(--bad-fg)";d+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${y}
<span>${escapeHtml(b.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(b.details||"")}</span>
</div>`}x.innerHTML=d,setTimeout(()=>m(c),500)}catch(r){x.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function m(g){C("results");const H=g.summary||{},r=H.failed===0&&H.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",d=r?"&#10003;":"&#9888;",b=r?"All Connected!":`${escapeHtml(String(H.succeeded))}/${escapeHtml(String(H.totalSteps))} Steps Succeeded`;let y=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${c};">${d}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${b}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(H.succeeded))} succeeded, ${escapeHtml(String(H.failed))} failed</div>
</div>`;y+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of g.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';y+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
</div>`}c+="</div>",H.innerHTML=c,$.style.display=b.failed>0?"block":"none",g.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&x()}async function x(){try{const b=await(await fetch("/api/v1/plex/libraries")).json();if(b.success&&b.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(b.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const p of b.libraries){const l=p.type==="movie"?"\u{1F3AC}":p.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${l} <strong>${escapeHtml(p.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(p.count))} items</span>
</div>`}s+="</div></div>",k.innerHTML=s,k.style.display="block"}}catch{}}B?.addEventListener("click",()=>{w.classList.add("show"),k.style.display="none",N()}),wireModal(w,A),S?.addEventListener("click",u),$?.addEventListener("click",u)})(),(function(){const w=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
</div>`}y+="</div>",$.innerHTML=y,T.style.display=H.failed>0?"block":"none",g.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&h()}async function h(){try{const H=await(await fetch("/api/v1/plex/libraries")).json();if(H.success&&H.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(H.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of H.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${d} <strong>${escapeHtml(c.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
</div>`}r+="</div></div>",k.innerHTML=r,k.style.display="block"}}catch{}}I?.addEventListener("click",()=>{f.classList.add("show"),k.style.display="none",R()}),wireModal(f,A),E?.addEventListener("click",u),T?.addEventListener("click",u)})(),(function(){const f=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3>
@@ -1082,15 +1000,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="notifications-save" class="btn-accent">Save Settings</button>
</div>
</div>
</div>`);const B=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),S=document.getElementById("notifications-save"),z=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const $=document.getElementById(`${k}-enabled`),E=document.getElementById(`${k}-config`);$?.addEventListener("change",()=>{E.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),M=document.getElementById("health-check-config");P?.addEventListener("change",()=>{M.style.opacity=P.checked?"1":"0.5"});async function D(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const E=$.config;document.getElementById("notifications-enabled").checked=E.enabled,document.getElementById("discord-enabled").checked=E.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=E.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=E.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=E.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=E.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=E.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=E.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=E.providers?.email?.enabled?"block":"none",E.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=E.providers.ntfy.serverUrl),E.providers?.email?.host&&(document.getElementById("email-host").value=E.providers.email.host),E.providers?.email?.from&&(document.getElementById("email-from").value=E.providers.email.from),document.getElementById("health-check-enabled").checked=E.healthCheck?.enabled||!1,E.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=E.healthCheck.intervalMinutes),E.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(E.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=E.events?.containerDown!==!1,document.getElementById("event-container-up").checked=E.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=E.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=E.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=E.events?.resourceAlert!==!1}}catch(k){w.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function h(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),E=document.getElementById("notification-history");$.success&&$.history?.length>0?E.innerHTML=$.history.map(I=>{const O=new Date(I.timestamp).toLocaleString();return`
</div>`);const I=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),E=document.getElementById("notifications-save"),M=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const T=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);T?.addEventListener("change",()=>{S.style.display=T.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");P?.addEventListener("change",()=>{z.style.opacity=P.checked?"1":"0.5"});async function D(){try{const T=await(await fetch("/api/v1/notifications/config")).json();if(T.success){const S=T.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(k){f.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function v(){try{const T=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");T.success&&T.history?.length>0?S.innerHTML=T.history.map(L=>{const j=new Date(L.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[I.type]||"var(--muted)"}">${I.type==="success"?"\u2713":I.type==="error"?"\u2717":"\u2139"}</span>
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[L.type]||"var(--muted)"}">${L.type==="success"?"\u2713":L.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(I.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${O}</div>
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(L.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div>
</div>
</div>
`}).join(""):E.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){w.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function T(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},E=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();E.success?(showNotification("Notification settings saved","success",3e3),B.classList.remove("show")):showNotification(`Failed to save: ${E.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function f(k){try{const E=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();E.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${E.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>f("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>f("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>f("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>f("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{B.classList.add("show"),D(),h()}),S?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),$=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const I=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();I.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${I.results?.map(O=>`${O.provider}: ${O.error||"ok"}`).join(", ")}`,"error",5e3)}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}finally{k.textContent=$,k.disabled=!1}});async function H(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){const E=document.getElementById("last-notification-sent");E&&(E.textContent=`Last sent: ${new Date($.lastSent).toLocaleString()}`)}}catch{}}wireModal(B,z)})(),(function(){document.addEventListener("click",w=>{const B=w.target.closest(".panel-tab");if(!B)return;const A=B.dataset.panel;if(!A)return;const S=B.closest(".panel-tabs"),z=S.closest(".weather-modal-content");S.querySelectorAll(".panel-tab").forEach(M=>M.classList.remove("active")),B.classList.add("active"),z.querySelectorAll(".panel-section").forEach(M=>M.classList.remove("active"));const P=z.querySelector("#"+A);P&&P.classList.add("active")})})(),(function(){var w=["dashcaddy_site_config","dashca Line truncated
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){f.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function B(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();S.success?(showNotification("Notification settings saved","success",3e3),I.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function x(k){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();S.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(T){showNotification(`Error: ${T.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>x("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>x("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>x("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>x("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const T=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();T.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(T.lastCheck).toLocaleString()} (${T.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{I.classList.add("show"),D(),v()}),E?.addEventListener("click",B),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),T=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const L=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();L.success?(showNotification("Test notification sent!","success",3e3),$()):showNotification(`Test failed: ${L.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=T,k.disabled=!1}});async function $(){try{const T=await(await fetch("/api/v1/notifications/status")).json();if(T.success&&T.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(T.lastSent).toLocaleString()}`)}}catch{}}wireModal(I,M)})(),(function(){document.addEventListener("click",f=>{const I=f.target.closest(".panel-tab");if(!I)return;const A=I.dataset.panel;if(!A)return;const E=I.closest(".panel-tabs"),M=E.closest(".weather-modal-content");E.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),I.classList.add("active"),M.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const P=M.querySelector("#"+A);P&&P.classList.add("active")})})(),(function(){var f=["dashcaddy_site_config","dashca Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle">
@@ -1196,7 +1114,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button>
</div>
</div>
</div>`);var P=document.getElementById("backup-modal"),M=document.getElementById("backup-restore-btn"),D=document.getElementById("backup-cancel"),h=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),f=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),E=document.getElementById("backup-do-restore-btn"),I=document.getElementById("backup-result"),O=document.getElementById("backup-schedules-container"),C=document.getElementById("backup-history-container"),j=document.getElementById("backup-disk-container"),N=document.getElementById("pointintime-container"),R=null;M?.addEventListener("click",function(){P.classList.add("show"),I&&(I.style.display="none"),k&&(k.style.display="none"),H&&(H.style.display="none"),R=null}),wireModal(P,D),h?.addEventListener("click",async function(){h.disabled=!0,h.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=B();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var r=Object.keys(a.browserState).length,d=a.themes?Object.keys(a.themes).length:0;I.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(d?" + "+d+" themes":""),I.style.display="block",I.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",I.style.border="1px solid var(--ok-fg)"}catch(y){I.innerHTML="\u274C Export failed: "+escapeHtml(y.message),I.style.display="block",I.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",I.style.border="1px solid var(--bad-fg)"}h.disabled=!1,h.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){f.click()}),f?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){H.textContent="\u{1F4C4} "+a.name,H.style.display="block",I.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(S(i)){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",$.innerHTML=n,k.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),d=await r.json();if(d.success){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var y in d.preview.files){var L=d.preview.files[y],U=L.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(L.description)+"</span>"}n+="</div>",d.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=n,k.style.display="block"}else I.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),I.style.display="block",I.style.background="color-mix(in srgb, #f39c12 15%, transparent)",I.style.border="1px solid #f39c12",k.style.display="none"}catch(q){I.innerHTML="\u274CLine truncated
</div>`);var P=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),D=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),B=document.getElementById("backup-select-file"),x=document.getElementById("backup-file-input"),$=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),T=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),L=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),C=document.getElementById("backup-history-container"),N=document.getElementById("backup-disk-container"),R=document.getElementById("pointintime-container"),U=null;z?.addEventListener("click",function(){P.classList.add("show"),L&&(L.style.display="none"),k&&(k.style.display="none"),$&&($.style.display="none"),U=null}),wireModal(P,D),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=I();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var s=Object.keys(a.browserState).length,l=a.themes?Object.keys(a.themes).length:0;L.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),L.style.display="block",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)"}catch(p){L.innerHTML="\u274C Export failed: "+escapeHtml(p.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),B?.addEventListener("click",function(){x.click()}),x?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){$.textContent="\u{1F4C4} "+a.name,$.style.display="block",L.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(E(i)){U=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",T.innerHTML=n,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await s.json();if(l.success){U=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var p in l.preview.files){var w=l.preview.files[p],O=w.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+O+" "+escapeHtml(w.description)+"</span>"}n+="</div>",l.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),T.innerHTML=n,k.style.display="block"}else L.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),L.style.display="block",L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12",k.style.display="none"}catch(_){L.innerHTML="\u274CLine truncated
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(a),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),i=await o.json();i.success?(showNotification(e+" restored to "+a,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(i.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
@@ -1278,11 +1196,11 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("stats-modal"),B=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),S=document.getElementById("stats-refresh-btn"),z=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),M=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),h=document.getElementById("stats-last-update");let T=null,f=null;function H(l){if(l===0||!l)return"0 B";const m=1024,c=["B","KB","MB","GB"],t=Math.floor(Math.log(l)/Math.log(m));return parseFloat((l/Math.pow(m,t)).toFixed(1))+" "+c[t]}function k(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function $(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function E(){try{let l=null,m=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,m=!0,f=e.stats)}catch{}if(!m){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const a of e.stats)l[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};f=l}}if(!l||Object.keys(l).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(l)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=k(o),r=$(i),d=a.memory?.usage||a.memory?.used||0,y=a.memory?.limit||0,L=a.network?.rxBytes||a.network?.rx||0,U=a.network?.txBytes||a.network?.tx||0,q=e.aggregated;c+=`
</div>`);const f=document.getElementById("stats-modal"),I=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),E=document.getElementById("stats-refresh-btn"),M=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let B=null,x=null;function $(d){if(d===0||!d)return"0 B";const b=1024,y=["B","KB","MB","GB"],t=Math.floor(Math.log(d)/Math.log(b));return parseFloat((d/Math.pow(b,t)).toFixed(1))+" "+y[t]}function k(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function T(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function S(){try{let d=null,b=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(d=e.stats,b=!0,x=e.stats)}catch{}if(!b){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){d={};for(const a of e.stats)d[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};x=d}}if(!d||Object.keys(d).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(d)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=k(o),s=T(i),l=a.memory?.usage||a.memory?.used||0,p=a.memory?.limit||0,w=a.network?.rxBytes||a.network?.rx||0,O=a.network?.txBytes||a.network?.tx||0,_=e.aggregated;y+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
${_?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${_.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
@@ -1299,23 +1217,23 @@ This will replace current configuration, credentials, and data. Containers will
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(i,100)}%; background: ${r}; border-radius: 3px; transition: width 0.3s;"></div>
<div style="height: 100%; width: ${Math.min(i,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${r}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(d)} / ${H(y)}</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${$(l)} / ${$(p)}</div>
</div>
<div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${H(L)}</span>
<span style="color: #3498db;">\u2193 ${$(w)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${H(U)}</span>
<span style="color: #e74c3c;">\u2191 ${$(O)}</span>
</div>
</div>
</div>
</div>`}c+="</div>",P.innerHTML=c,h.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function I(){if(!M)return;const l=f;if(!l||Object.keys(l).length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let m='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,t]of Object.entries(l)){const e=t.aggregated;e&&(m+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||c}</div>
</div>`}y+="</div>",P.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(d.message)}</div>`}}async function L(){if(!z)return;const d=x;if(!d||Object.keys(d).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let b='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[y,t]of Object.entries(d)){const e=t.aggregated;e&&(b+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||y}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
@@ -1323,27 +1241,27 @@ This will replace current configuration, credentials, and data. Containers will
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div>
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
</div>`)}m+="</div>",M.innerHTML=m}async function O(){if(!D)return;D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const l=f;if(!l||Object.keys(l).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let m=!1;try{m=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{m=!1}let c=[];try{const r=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();r.success&&(c=r.history||[])}catch{}let t={};try{const r=await(await fetch("/api/v1/monitoring/alerts/config")).json();r.success&&(t=r.configs||{})}catch{}const a=Object.entries(l).map(([n,r])=>{const d=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
</div>`)}b+="</div>",z.innerHTML=b}async function j(){if(!D)return;D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=x;if(!d||Object.keys(d).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let b=!1;try{b=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{b=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let t={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(t=s.configs||{})}catch{}const a=Object.entries(d).map(([n,s])=>{const l=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
<tr data-container="${n}">
<td style="font-weight: 600; padding: 8px;">${r.name||n}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${d.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${m?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${d.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${m?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${d.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${m?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${d.autoRestart?"checked":""} ${m?"":"disabled"} /></td>
<td style="font-weight: 600; padding: 8px;">${s.name||n}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${b?"":"disabled"} /></td>
<td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${r.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${s.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
</td>
</tr>
`}).join(""),o=c.map(n=>{const r=new Date(n.timestamp).toLocaleString(),d=n.notified?"\u2713":"\u2014";return`
`}).join(""),o=y.map(n=>{const s=new Date(n.timestamp).toLocaleString(),l=n.notified?"\u2713":"\u2014";return`
<tr>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${r}</td>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
<td style="padding: 6px 8px; font-weight: 500;">${n.containerName||n.containerId}</td>
<td style="padding: 6px 8px; text-transform: capitalize;">${n.metric||n.type}</td>
<td style="padding: 6px 8px;">${typeof n.value=="number"?n.value.toFixed(1):n.value}${n.metric==="disk"?" MB/s":"%"}</td>
<td style="padding: 6px 8px; text-align: center;">${d}</td>
<td style="padding: 6px 8px; text-align: center;">${l}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td>
</tr>
`}).join(""),i=m?`
`}).join(""),i=b?`
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
@@ -1396,21 +1314,21 @@ This will replace current configuration, credentials, and data. Containers will
</div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(r=>{const d=r.dataset.container;n[d]={cpuThreshold:parseInt(r.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(r.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(r.querySelector(".alert-disk")?.value)||50,autoRestart:!!r.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const d=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),y=document.getElementById("save-all-alerts");y.textContent=d.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{y.textContent="Save All"},2e3)}catch{const d=document.getElementById("save-all-alerts");d.textContent="\u274C Error",setTimeout(()=>{d.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),w.classList.remove("show"),j(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=r},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{w.classList.remove("show"),j(),typeof openLicenseModal=="function"&&openLicenseModal()})}function C(){T&&clearInterval(T),z?.checked&&(T=setInterval(E,DC.POLL.STATS))}function j(){T&&(clearInterval(T),T=null)}B?.addEventListener("click",()=>{w.classList.add("show"),E(),C()}),A?.addEventListener("click",()=>{w.classList.remove("show"),j()}),w?.addEventListener("click",l=>{l.target===w&&(w.classList.remove("show"),j())}),S?.addEventListener("click",E),z?.addEventListener("change",()=>{z.checked?C():j()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",I),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",O);const N=document.getElementById("stats-history-container"),R=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let v="1h";function x(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function b(l,m,c,t,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=l.map(m).filter(_=>_!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,r=600,d=80,y=4,L=(r-y*2)/Math.max(a.length-1,1),U=a.map((_,J)=>{const X=y+J*L,Q=d-y-(_-i)/n*(d-y*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=a[a.length-1],F=a.reduce((_,J)=>_+J,0)/a.length;return`
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;n[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),p=document.getElementById("save-all-alerts");p.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{p.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),f.classList.remove("show"),N(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{f.classList.remove("show"),N(),typeof openLicenseModal=="function"&&openLicenseModal()})}function C(){B&&clearInterval(B),M?.checked&&(B=setInterval(S,DC.POLL.STATS))}function N(){B&&(clearInterval(B),B=null)}I?.addEventListener("click",()=>{f.classList.add("show"),S(),C()}),A?.addEventListener("click",()=>{f.classList.remove("show"),N()}),f?.addEventListener("click",d=>{d.target===f&&(f.classList.remove("show"),N())}),E?.addEventListener("click",S),M?.addEventListener("change",()=>{M.checked?C():N()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",L),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const R=document.getElementById("stats-history-container"),U=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let m="1h";function h(d){switch(d){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function H(d,b,y,t,e){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=d.map(b).filter(q=>q!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,s=600,l=80,p=4,w=(s-p*2)/Math.max(a.length-1,1),O=a.map((q,J)=>{const X=p+J*w,Q=l-p-(q-i)/n*(l-p*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=a[a.length-1],F=a.reduce((q,J)=>q+J,0)/a.length;return`
<div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${o.toFixed(1)}${e}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${o.toFixed(1)}${e}</span>
</div>
<svg viewBox="0 0 ${r} ${d}" preserveAspectRatio="none" style="width: 100%; height: ${d}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${c}" stroke-width="1.5" points="${U}" />
<svg viewBox="0 0 ${s} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${y}" stroke-width="1.5" points="${O}" />
</svg>
</div>
`}function s(){if(!N)return;const l=f||{},m=N.value,c=Object.entries(l);if(c.length===0){N.innerHTML='<option value="">No containers</option>';return}N.innerHTML=c.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),m&&l[m]&&(N.value=m)}async function p(){if(!R||!N)return;const l=N.value;if(!l){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const m=Date.now(),c=m-x(v);R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(l)}?startTime=${c}&endTime=${m}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){R.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${v}. Tier: ${g(o)}.</div>`;return}const i=o==="raw",n=i?U=>U.cpu?.percent:U=>U.cpu?.avg,r=i?U=>U.memory?.percent:U=>U.memory?.avgPercent,d=i?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,y=i?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let L=`
`}function r(){if(!R)return;const d=x||{},b=R.value,y=Object.entries(d);if(y.length===0){R.innerHTML='<option value="">No containers</option>';return}R.innerHTML=y.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),b&&d[b]&&(R.value=b)}async function c(){if(!U||!R)return;const d=R.value;if(!d){U.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const b=Date.now(),y=b-h(m);U.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${y}&endTime=${b}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${g(o)}.</div>`;return}const i=o==="raw",n=i?O=>O.cpu?.percent:O=>O.cpu?.avg,s=i?O=>O.memory?.percent:O=>O.memory?.avgPercent,l=i?O=>O.network?.rxMB||0:O=>O.network?.rxMB||0,p=i?O=>O.network?.txMB||0:O=>O.network?.txMB||0;let w=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${a.length} samples \xB7 ${escapeHtml(g(o))} \xB7 ${new Date(c).toLocaleString()} \u2192 ${new Date(m).toLocaleString()}
${a.length} samples \xB7 ${escapeHtml(g(o))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date(b).toLocaleString()}
</div>
`;L+=b(a,n,"#2ecc71","CPU","%"),L+=b(a,r,"#3498db","Memory","%"),L+=b(a,d,"#9b59b6","Network RX"," MB"),L+=b(a,y,"#e67e22","Network TX"," MB"),R.innerHTML=L}catch(t){R.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}u.forEach(l=>{l.addEventListener("click",()=>{u.forEach(m=>m.classList.remove("active")),l.classList.add("active"),v=l.dataset.range,p()})}),N?.addEventListener("change",p),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),p()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
`;w+=H(a,n,"#2ecc71","CPU","%"),w+=H(a,s,"#3498db","Memory","%"),w+=H(a,l,"#9b59b6","Network RX"," MB"),w+=H(a,p,"#e67e22","Network TX"," MB"),U.innerHTML=w}catch(t){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}u.forEach(d=>{d.addEventListener("click",()=>{u.forEach(b=>b.classList.remove("active")),d.classList.add("active"),m=d.dataset.range,c()})}),R?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle">
@@ -1534,24 +1452,24 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("health-modal"),B=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),S=document.getElementById("health-refresh-btn"),z=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),M=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),h=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),f=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save"),$="dashcaddy-health-settings",E={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},I=document.getElementById("health-global-save"),O=document.getElementById("health-global-reset"),C=document.getElementById("health-global-status"),j=document.getElementById("health-setting-retention"),N=document.getElementById("health-setting-interval"),R=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),v=document.getElementById("health-setting-disk-threshold");function x(){try{const i=safeGet($),n=i?JSON.parse(i):{};return Object.assign({},E,n)}catch{return Object.assign({},E)}}function g(){const i=x();j&&(j.value=i.retentionDays),N&&(N.value=i.pollingInterval),R&&(R.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),v&&(v.value=i.diskUsageThreshold)}function b(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(j?.value)||E.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(N?.value)||E.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(R?.value)||E.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||E.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(v?.value)||E.diskUsageThreshold))};try{safeSet($,JSON.stringify(i)),g(),C&&(C.textContent="Saved \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){C&&(C.textContent="Save failed",C.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function s(){try{safeSet($,JSON.stringify(E))}catch{}g(),C&&(C.textContent="Reset to defaults \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500))}g(),I?.addEventListener("click",b),O?.addEventListener("click",s);let p=null;function l(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function m(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function c(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const r=Object.values(n.status);let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',d+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',d+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',d+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const y of r){const L=y.status==="up",U=L?"var(--dot-ok)":"var(--dot-bad)",q=y.uptime?.["24h"]??"-",F=y.uptime?.["7d"]??"-",_=y.avgResponseTime!=null?Math.round(y.avgResponseTime)+"ms":"-",J=y.timestamp?timeAgo(y.timestamp):"-";d+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(y.serviceId)}">`,d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${L?"Up":"Down"}</td>`,d+=`<td style="padding: 8px; color: ${typeof q=="number"?l(q):"var(--muted)"};">${typeof q=="number"?q.toFixed(1)+"%":q}</td>`,d+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,d+=`<td style="padding: 8px;">${_}</td>`,d+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,d+="</tr>",d+=`<tr id="health-detail-${escapeHtml(y.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
</div>`);const f=document.getElementById("health-modal"),I=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),E=document.getElementById("health-refresh-btn"),M=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),B=document.getElementById("health-config-form"),x=document.getElementById("health-form-title"),$=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save"),T="dashcaddy-health-settings",S={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},L=document.getElementById("health-global-save"),j=document.getElementById("health-global-reset"),C=document.getElementById("health-global-status"),N=document.getElementById("health-setting-retention"),R=document.getElementById("health-setting-interval"),U=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),m=document.getElementById("health-setting-disk-threshold");function h(){try{const i=safeGet(T),n=i?JSON.parse(i):{};return Object.assign({},S,n)}catch{return Object.assign({},S)}}function g(){const i=h();N&&(N.value=i.retentionDays),R&&(R.value=i.pollingInterval),U&&(U.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),m&&(m.value=i.diskUsageThreshold)}function H(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(N?.value)||S.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(R?.value)||S.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(U?.value)||S.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||S.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(m?.value)||S.diskUsageThreshold))};try{safeSet(T,JSON.stringify(i)),g(),C&&(C.textContent="Saved \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){C&&(C.textContent="Save failed",C.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function r(){try{safeSet(T,JSON.stringify(S))}catch{}g(),C&&(C.textContent="Reset to defaults \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500))}g(),L?.addEventListener("click",H),j?.addEventListener("click",r);let c=null;function d(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function b(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function y(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const s=Object.values(n.status);let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',l+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',l+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',l+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const p of s){const w=p.status==="up",O=w?"var(--dot-ok)":"var(--dot-bad)",_=p.uptime?.["24h"]??"-",F=p.uptime?.["7d"]??"-",q=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",J=p.timestamp?timeAgo(p.timestamp):"-";l+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(p.serviceId)}">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${O}; margin-right: 6px;"></span>${w?"Up":"Down"}</td>`,l+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,l+=`<td style="padding: 8px; color: ${typeof F=="number"?d(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,l+=`<td style="padding: 8px;">${q}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,l+="</tr>",l+=`<tr id="health-detail-${escapeHtml(p.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${_.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${l(_.uptime||0)};">${(_.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${q.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${d(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(J.avg||0)}ms</strong></div>
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(J.p95||0)}ms / ${Math.round(J.p99||0)}ms</strong></div>
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(J.min||0)}ms</strong></div>
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(J.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${_.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${_.downChecks||0}</strong></div>
</div>`}else U.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(q){U.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(q.message)}</div>`}}})})}catch(i){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),r=await i.json(),d=await n.json();let y="";const L=r.success&&r.incidents?r.incidents:[];if(L.length>0){y+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+L.length+")</h4>";for(const q of L)y+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${q.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${q.downChecks||0}</strong></div>
</div>`}else O.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){O.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(i){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),s=await i.json(),l=await n.json();let p="";const w=s.success&&s.incidents?s.incidents:[];if(w.length>0){p+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+w.length+")</h4>";for(const _ of w)p+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(q.serviceId)}</span>
<span>${m(q.severity)}</span>
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
<span>${b(_.severity)}</span>
</div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(q.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(q.createdAt)} \xB7 ${q.occurrences||1} occurrence(s)</div>
</div>`;y+="</div>"}else y+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=d.success&&d.history?d.history:[];if(U.length>0){y+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',y+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',y+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const q of U){const F=q.status==="resolved",_=F&&q.duration?q.duration<6e4?Math.round(q.duration/1e3)+"s":Math.round(q.duration/6e4)+"m":"-";y+='<tr style="border-bottom: 1px solid var(--border);">',y+=`<td style="padding: 6px;">${escapeHtml(q.serviceId)}</td>`,y+=`<td style="padding: 6px;">${escapeHtml(q.type)}</td>`,y+=`<td style="padding: 6px;">${m(q.severity)}</td>`,y+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${q.status}</span></td>`,y+=`<td style="padding: 6px;">${_}</td>`,y+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(q.createdAt)}</td>`,y+="</tr>"}y+="</table>"}P.innerHTML=y||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),r=n.success&&n.status?Object.values(n.status):[];if(r.length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const y of r){const L=y.status==="up";d+='<tr style="border-bottom: 1px solid var(--border);">',d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px; color: ${L?"var(--ok-fg)":"var(--bad-fg)"};">${L?"Up":"Down"}</td>`,d+=`<td style="padding: 8px;">${y.sla?.target?y.sla.target+"%":"-"}</td>`,d+='<td style="padding: 8px; text-align: right;">',d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,d+="</td></tr>"}d+="</table>",M.innerHTML=d}catch(i){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,r,d,y,L,U){p=i||null,f.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=r||"",document.getElementById("health-form-timeout").value=d||1e4,document.getElementById("health-form-codes").value=y||"200",document.getElementById("health-form-sla").value=L||99.9,document.getElementById("health-form-slow").value=U||5e3,T.style.display="",h.style.display="none"}function o(){T.style.display="none",h.style.display="",p=null}h?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",o),k?.addEventListener("click",async()=>{const i=p||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const r=document.getElementById("health-form-codes").value.split(",").map(y=>parseInt(y.trim())).filter(Boolean),d={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:r.length?r:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(_.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(_.createdAt)} \xB7 ${_.occurrences||1} occurrence(s)</div>
</div>`;p+="</div>"}else p+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const O=l.success&&l.history?l.history:[];if(O.length>0){p+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',p+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of O){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,p+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,p+=`<td style="padding: 6px;">${b(_.severity)}</td>`,p+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,p+=`<td style="padding: 6px;">${q}</td>`,p+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,p+="</tr>"}p+="</table>"}P.innerHTML=p||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),s=n.success&&n.status?Object.values(n.status):[];if(s.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const p of s){const w=p.status==="up";l+='<tr style="border-bottom: 1px solid var(--border);">',l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px; color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"Up":"Down"}</td>`,l+=`<td style="padding: 8px;">${p.sla?.target?p.sla.target+"%":"-"}</td>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,l+="</td></tr>"}l+="</table>",z.innerHTML=l}catch(i){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,s,l,p,w,O){c=i||null,x.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=s||"",document.getElementById("health-form-timeout").value=l||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=w||99.9,document.getElementById("health-form-slow").value=O||5e3,B.style.display="",v.style.display="none"}function o(){B.style.display="none",v.style.display="",c=null}v?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),$?.addEventListener("click",o),k?.addEventListener("click",async()=>{const i=c||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const s=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),l={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:s.length?s:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle">
@@ -1625,17 +1543,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("updates-modal"),B=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),S=document.getElementById("updates-check-btn"),z=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function h(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const r=(()=>{const d=window.APPS||[];for(const y of d)if(y.containerId===n.containerId||y.name===n.containerName||y.id===n.containerName)return y.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(r)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",z.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,z.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Update "${d}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const L=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(L.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>h(),2e3);else throw new Error(L.error||"Update failed")}catch(y){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+y.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),z.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Rollback "${d}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const L=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(r)}`,{method:"POST"})).json();if(L.success)n.textContent="Rolled back!",setTimeout(()=>h(),2e3);else throw new Error(L.error||"Rollback failed")}catch(y){n.textContent="Failed",showNotification("Rollback error: "+y.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(c){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function T(){const c=window._pendingUpdates||[];if(!c.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${c.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of c)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(y)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${U?"":" selected"}>Disabled</option>
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(y)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(y)}"${q?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${_}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(y)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",M.innerHTML=n,M.querySelectorAll(".save-auto-btn").forEach(r=>{r.addEventListener("click",async()=>{const d=r.dataset.id,y=r.closest("tr"),L=y.querySelector(".auto-schedule").value,U=y.querySelector(".auto-rollback").checked,q=y.querySelector(".auto-window").value.trim();r.textContent="Saving...",r.disabled=!0;try{const _=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!L,schedule:L||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(_.success)r.textContent="\u2713 Saved";else throw new Error(_.error)}catch(F){r.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{r.textContent="Save",r.disabled=!1},2e3)})})}catch(c){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),E=document.getElementById("dashcaddy-update-badge"),I=document.getElementById("dashcaddy-update-details"),O=document.getElementById("dashcaddy-new-version"),C=document.getElementById("dashcaddy-changelog"),j=document.getElementById("dashcaddy-apply-btn"),N=document.getElementById("dashcaddy-check-btn"),R=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),v=document.getElementById("dashcaddy-history-container");let x=null;function g(c,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=c)}async function b(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(c){c||(N.textContent="Checking...",N.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(x=e,e.success&&e.available&&e.remote){E.style.display="",I.style.display="",O.textContent="v"+e.remote.version,C.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else E.style.display="none",I.style.display="none",await b(),c||g("You are running the latest version.","success");c||(N.textContent="Check for Updates",N.disabled=!1)}catch(t){c||(g("Failed to check: "+t.message,"error"),N.textContent="Check for Updates",N.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;j.textContent="Updating...",j.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),j.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(c){throw g("Update failed: "+c.message,"error"),j.textContent="Update Now",j.disabled=!1,c}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){v.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
</div>`);const f=document.getElementById("updates-modal"),I=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),E=document.getElementById("updates-check-btn"),M=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function v(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const s=(()=>{const l=window.APPS||[];for(const p of l)if(p.containerId===n.containerId||p.name===n.containerName||p.id===n.containerName)return p.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",M.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,M.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(w.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(w.error||"Update failed")}catch(p){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+p.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),M.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(w.success)n.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(w.error||"Rollback failed")}catch(p){n.textContent="Failed",showNotification("Rollback error: "+p.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(y){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function B(){const y=window._pendingUpdates||[];if(!y.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(p)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${O?"":" selected"}>Disabled</option>
<option value="daily"${O==="daily"?" selected":""}>Daily</option>
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(p)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(p)}"${_?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(p)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",z.innerHTML=n,z.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,p=s.closest("tr"),w=p.querySelector(".auto-schedule").value,O=p.querySelector(".auto-rollback").checked,_=p.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!w,schedule:w||"weekly",autoRollback:O,maintenanceWindow:_||void 0})})).json();if(q.success)s.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){s.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}const T=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),L=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),C=document.getElementById("dashcaddy-changelog"),N=document.getElementById("dashcaddy-apply-btn"),R=document.getElementById("dashcaddy-check-btn"),U=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let h=null;function g(y,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=y)}async function H(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;T.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{T.textContent="Unable to fetch version"}}async function r(y){y||(R.textContent="Checking...",R.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(h=e,e.success&&e.available&&e.remote){S.style.display="",L.style.display="",j.textContent="v"+e.remote.version,C.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else S.style.display="none",L.style.display="none",await H(),y||g("You are running the latest version.","success");y||(R.textContent="Check for Updates",R.disabled=!1)}catch(t){y||(g("Failed to check: "+t.message,"error"),R.textContent="Check for Updates",R.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;N.textContent="Updating...",N.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),N.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(y){throw g("Update failed: "+y.message,"error"),N.textContent="Update Now",N.disabled=!1,y}}async function d(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+e.join(`
`)+`
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(c){g("Rollback failed: "+c.message,"error")}}N?.addEventListener("click",()=>s(!1)),j?.addEventListener("click",()=>p().catch(()=>{})),R?.addEventListener("click",m),S?.addEventListener("click",f),B?.addEventListener("click",()=>{w?.classList.add("show"),h()}),wireModal(w,A),window.openUpdateModal=function(c){w?.classList.add("show"),h().then(()=>{if(!c)return;const t=z.querySelector(`[data-app-id="${c}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{b(),l(),x||s(!0)}),window.dcApplyUpdate=p,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(y){g("Rollback failed: "+y.message,"error")}}R?.addEventListener("click",()=>r(!1)),N?.addEventListener("click",()=>c().catch(()=>{})),U?.addEventListener("click",b),E?.addEventListener("click",x),I?.addEventListener("click",()=>{f?.classList.add("show"),v()}),wireModal(f,A),window.openUpdateModal=function(y){f?.classList.add("show"),v().then(()=>{if(!y)return;const t=M.querySelector(`[data-app-id="${y}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",$),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{H(),d(),h||r(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=r,setTimeout(()=>r(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1684,7 +1602,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="dr-close">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("docker-resources-modal"),B=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function S(D){if(!D||D===0)return"0 B";const h=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,T)).toFixed(1)+" "+h[T]}async function z(){const D=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const k=H.name==="buildkit"||H.name.length===64;f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(H.name)}">${escapeHtml(H.name.length>40?H.name.substring(0,37)+"...":H.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,f+='<td style="padding: 6px; text-align: right;">',k||(f+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",D.innerHTML=f,D.querySelectorAll(".dr-vol-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete volume "${H.dataset.name}"? Data will be lost.`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(H.dataset.name)}?force=true`),z()}catch(k){showNotification("Delete failed: "+k.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(h){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),h=D.value.trim();if(!h){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:h}),D.value="",showNotification(`Volume "${h}" created`,"success"),z()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const k=["bridge","host","none"].includes(H.name);f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,f+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,f+='<td style="padding: 6px; text-align: right;">',k||(f+=`<button class="dr-net-del" data-id="${escapeHtml(H.id)}" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",D.innerHTML=f,D.querySelectorAll(".dr-net-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete network "${H.dataset.name}"?`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(H.dataset.id)}`),P()}catch(k){showNotification("Delete failed: "+k.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(h){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),h=document.getElementById("dr-net-driver"),T=D.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:h.value}),D.value="",showNotification(`Network "${T}" created`,"success"),P()}catch(f){showNotification("Create failed: "+f.message,"error")}});async fuLine truncated
</div>`);const f=document.getElementById("docker-resources-modal"),I=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function E(D){if(!D||D===0)return"0 B";const v=["B","KB","MB","GB","TB"],B=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,B)).toFixed(1)+" "+v[B]}async function M(){const D=document.getElementById("dr-vol-list");try{const B=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(B.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of B){const k=$.name==="buildkit"||$.name.length===64;x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml($.name)}">${escapeHtml($.name.length>40?$.name.substring(0,37)+"...":$.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-vol-del" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",D.innerHTML=x,D.querySelectorAll(".dr-vol-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete volume "${$.dataset.name}"? Data will be lost.`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent($.dataset.name)}?force=true`),M()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),v=D.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),D.value="",showNotification(`Volume "${v}" created`,"success"),M()}catch(B){showNotification("Create failed: "+B.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const B=(await getJSON("/api/v1/docker/networks")).networks||[];if(B.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of B){const k=["bridge","host","none"].includes($.name);x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml($.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+=`<td style="padding: 6px; text-align: center;">${$.containers}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-net-del" data-id="${escapeHtml($.id)}" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",D.innerHTML=x,D.querySelectorAll(".dr-net-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete network "${$.dataset.name}"?`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent($.dataset.id)}`),P()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),B=D.value.trim();if(!B){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:B,driver:v.value}),D.value="",showNotification(`Network "${B}" created`,"success"),P()}catch(x){showNotification("Create failed: "+x.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1725,8 +1643,8 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<div id="compose-progress-content"></div>
</div>
</div>
</div>`);const w=document.getElementById("compose-import-modal"),B=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(w,A);let S=null;function z(M){document.getElementById("compose-step-paste").style.display=M==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=M==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=M==="progress"?"":"none"}B?.addEventListener("click",()=>{z("paste"),S=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",w?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",M=>{const D=M.target.files[0];if(!D)return;const h=new FileReader;h.onload=()=>{document.getElementById("compose-yaml").value=h.result},h.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const M=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!M){showNotification("Paste a docker-compose.yml","warning");return}const h=document.getElementById("compose-parse-btn"),T=h.textContent;h.textContent="Parsing...",h.disabled=!0;try{const f=await postJSON("/api/v1/apps/import-compose",{yaml:M,stackName:D});S=f,S.stackName=D,P(f),z("preview")}catch(f){showNotification("Parse failed: "+f.message,"error")}finally{h.textContent=T,h.disabled=!1}});function P(M){const D=document.getElementById("compose-preview-content");let h="";M.networks&&M.networks.length>0&&(h+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${M.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),M.volumes&&M.volumes.length>0&&(h+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${M.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),h+=`<div style="font-weight: 600; margin-bottom: 8px;">${M.services.length} service(s)</div>`,h+='<div class="scroll-container" style="max-height: 350px;">';for(const T of M.services){const f=T.skip?"var(--bad-fg)":"var(--border)";if(h+=`<div style="padding: 10px 14px; border: 1px solid ${f}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,h+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(h+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),h+="</div>",!T.skip&&(h+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),T.volumes?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(h+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(T.envFileWarning)}</div>`),T.resources?.cpus||T.resources?.memory)){const H=[];T.resources.cpus&&H.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&H.push(`Mem: ${T.resources.memory}MB`),h+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}h+="</div>"}h+="</div>",D.innerHTML=h}document.getElementById("compose-back-btn")?.addEventListener("click",()=>z("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!S)return;const M=document.getElementById("compose-deploy-btn");M.textContent="Deploying...",M.disabled=!0,z("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const h=await postJSON("/api/v1/apps/deploy-compose",{services:S.services,networks:S.networks,stackName:S.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(h.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const f of h.results){const H=f.status==="deployed"||f.status==="created"?"\u2705":f.status==="exists"?"\u26A1":f.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${H} <strong>${escapeHtml(f.name)}</strong> (${f.type}) \u2014 ${escapeHtml(f.status)}`,f.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(f.error)}</span>`),f.subdomain&&(T+=` \u2192 <code>${escapeHtml(f.subdomain)}</code>`),f.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(f.reason)})</span>`),T+="</div>"}T+="</div>",T+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>z("paste"))}finally{M.textContent="Deploy All",M.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
</div>`);const f=document.getElementById("compose-import-modal"),I=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(f,A);let E=null;function M(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}I?.addEventListener("click",()=>{M("paste"),E=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",f?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const D=z.target.files[0];if(!D)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),B=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const x=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:D});E=x,E.stackName=D,P(x),M("preview")}catch(x){showNotification("Parse failed: "+x.message,"error")}finally{v.textContent=B,v.disabled=!1}});function P(z){const D=document.getElementById("compose-preview-content");let v="";z.networks&&z.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const B of z.services){const x=B.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${x}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(B.name)}`,B.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(B.reason)}</span>`),v+="</div>",!B.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(B.image)}</code></div>`,B.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${B.ports.map($=>`${$.host}:${$.container}`).join(", ")}</div>`),B.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${B.volumes.length}</div>`),Object.keys(B.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(B.environment).length}</div>`),B.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(B.envFileWarning)}</div>`),B.resources?.cpus||B.resources?.memory)){const $=[];B.resources.cpus&&$.push(`CPU: ${B.resources.cpus}`),B.resources.memory&&$.push(`Mem: ${B.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${$.join(", ")}</div>`}v+="</div>"}v+="</div>",D.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>M("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!E)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,M("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:E.services,networks:E.networks,stackName:E.stackName});let B=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;B+='<div class="scroll-container" style="max-height: 350px;">';for(const x of v.results){const $=x.status==="deployed"||x.status==="created"?"\u2705":x.status==="exists"?"\u26A1":x.status==="skipped"?"\u23ED":"\u274C";B+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',B+=`${$} <strong>${escapeHtml(x.name)}</strong> (${x.type}) \u2014 ${escapeHtml(x.status)}`,x.error&&(B+=` <span style="color: var(--bad-fg);">${escapeHtml(x.error)}</span>`),x.subdomain&&(B+=` \u2192 <code>${escapeHtml(x.subdomain)}</code>`),x.reason&&(B+=` <span style="color: var(--muted);">(${escapeHtml(x.reason)})</span>`),B+="</div>"}B+="</div>",B+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>M("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
@@ -1734,21 +1652,21 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="exec-close">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("exec-modal"),B=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let S=null,z=null,P=null;function M(){if(z){try{z.close()}catch{}z=null}if(S){try{S.dispose()}catch{}S=null}P=null,B.innerHTML=""}function D(h,T){if(M(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||h}`,w?.classList.add("show"),typeof Terminal>"u"){B.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}S=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,S.loadAddon(P)),S.open(B),P&&setTimeout(()=>P.fit(),50);const f=location.protocol==="https:"?"wss:":"ws:";z=new WebSocket(`${f}//${location.host}/ws/exec/${encodeURIComponent(h)}`),z.binaryType="arraybuffer",z.onopen=()=>{if(S.writeln("\x1B[32mConnecting...\x1B[0m"),P){const k=P.proposeDimensions();k&&z.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},z.onmessage=k=>{if(typeof k.data=="string"){try{const $=JSON.parse(k.data);if($.type==="connected"){S.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r
`);return}if($.type==="error"){S.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){S.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}S.write(k.data)}else S.write(new Uint8Array(k.data))},z.onclose=()=>{S&&S.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},z.onerror=()=>{S&&S.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},S.onData(k=>{z&&z.readyState===WebSocket.OPEN&&z.send(k)}),S.onResize(({cols:k,rows:$})=>{z&&z.readyState===WebSocket.OPEN&&z.send(JSON.stringify({type:"resize",cols:k,rows:$}))});const H=()=>{P&&P.fit()};window.addEventListener("resize",H),w._resizeHandler=H}A?.addEventListener("click",()=>{M(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show")}),w?.addEventListener("click",h=>{h.target===w&&(M(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show"))}),window.openExecModal=D})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
</div>`);const f=document.getElementById("exec-modal"),I=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let E=null,M=null,P=null;function z(){if(M){try{M.close()}catch{}M=null}if(E){try{E.dispose()}catch{}E=null}P=null,I.innerHTML=""}function D(v,B){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${B||v}`,f?.classList.add("show"),typeof Terminal>"u"){I.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}E=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,E.loadAddon(P)),E.open(I),P&&setTimeout(()=>P.fit(),50);const x=location.protocol==="https:"?"wss:":"ws:";M=new WebSocket(`${x}//${location.host}/ws/exec/${encodeURIComponent(v)}`),M.binaryType="arraybuffer",M.onopen=()=>{if(E.writeln("\x1B[32mConnecting...\x1B[0m"),P){const k=P.proposeDimensions();k&&M.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},M.onmessage=k=>{if(typeof k.data=="string"){try{const T=JSON.parse(k.data);if(T.type==="connected"){E.writeln(`\x1B[32mConnected (${T.shell})\x1B[0m\r
`);return}if(T.type==="error"){E.writeln(`\x1B[31mError: ${T.message}\x1B[0m`);return}if(T.type==="exit"){E.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}E.write(k.data)}else E.write(new Uint8Array(k.data))},M.onclose=()=>{E&&E.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},M.onerror=()=>{E&&E.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},E.onData(k=>{M&&M.readyState===WebSocket.OPEN&&M.send(k)}),E.onResize(({cols:k,rows:T})=>{M&&M.readyState===WebSocket.OPEN&&M.send(JSON.stringify({type:"resize",cols:k,rows:T}))});const $=()=>{P&&P.fit()};window.addEventListener("resize",$),f._resizeHandler=$}A?.addEventListener("click",()=>{z(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show")}),f?.addEventListener("click",v=>{v.target===f&&(z(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show"))}),window.openExecModal=D})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
<h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle">
Track all actions performed through the API.
</p>
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
<label class="text-muted-sm">Category:</label>
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
<label class="text-muted-sm">Filter:</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</option>
<option value="">All Actions</option>
<option value="service">Services</option>
<option value="container">Containers</option>
<option value="caddy">Caddy</option>
@@ -1757,16 +1675,6 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<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">\u2713 Success</option>
<option value="failure">\u2717 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">\u{1F504} 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);">\u{1F5D1}\uFE0F Clear Log</button>
@@ -1784,7 +1692,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="audit-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("audit-modal"),B=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),S=document.getElementById("audit-refresh-btn"),z=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),M=document.getElementById("audit-outcome-filter"),D=document.getElementById("audit-since"),h=document.getElementById("audit-until"),T=document.getElementById("audit-log-container"),f=document.getElementById("audit-load-more");let H=0,k=null,$=0;const E=50;function I(N){if(!N)return null;const R=new Date(N);return isNaN(R.getTime())?null:R.toISOString()}async function O(N){try{N?(k&&k.abort(),k=new AbortController):(k&&k.abort(),k=new AbortController,H=0,$++,T.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const R=$,u=new URLSearchParams;u.set("limit",String(E)),u.set("offset",String(H));const v=P.value,x=M.value,g=I(D.value),b=I(h.value);v&&u.set("action",v),x&&u.set("outcome",x),g&&u.set("since",g),b&&u.set("until",b);const s=await fetch("/api/v1/audit-logs?"+u.toString(),{signal:k.signal});if(!s.ok){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${s.status}</div>`,f.style.display="none";return}const p=await s.json();if(!p.success){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.error||"unknown")}</div>`,f.style.display="none";return}if(!N&&R!==$)return;const l=Array.isArray(p.entries)?p.entries:[];if(l.length===0&&!N){const c=p.filters&&(p.filters.action||p.filters.outcome||p.filters.since||p.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(c)}</div>`,f.style.display="none";return}let m="";N||(m='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',m+='<th style="padding: 6px; text-align: left;">When</th>',m+='<th style="padding: 6px; text-align: left;">Actor</th>',m+='<th style="padding: 6px; text-align: left;">IP</th>',m+='<th style="padding: 6px; text-align: left;">Action</th>',m+='<th style="padding: 6px; text-align: left;">Resource</th>',m+='<th style="padding: 6px; text-align: left;">Result</th>',m+="</tr>");for(const c of l){const t=c.outcome==="success",e=C(c);m+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',m+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(c.timestamp||"")}">${timeAgo(c.timestamp)}</td>`,m+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,m+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c.ip||"-")}</td>`,m+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(c.action||"-")}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(c.resource||"-")}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")}</span></td>`,m+="</tr>",c.details&&Object.keys(c.details).length>0&&(m+=`<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(c.details,null,2))}</pre></td></tr>`)}if(!N)m+="</table>",T.innerHTML=m;else{const c=T.querySelector("table");c&&c.insertAdjacentHTML("beforeend",m)}H+=l.length,f.style.display=p.hasMore?"":"none",T.querySelectorAll(".audit-row").forEach(c=>{c.dataset.wired||(c.dataset.wired="true",c.addEventListener("click",()=>{const t=c.nextElementSibling;t&&t.classList.contains("audit-detail")&&(t.style.display=t.style.display==="none"?"":"none")}))})}catch(R){if(R&&R.name==="AbortError")return;T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(R.message)}</div>`}}function C(N){const R=N.details||{},u=R.userEmail,v=R.userId,x=R.userRole,g=R.viaProvider;if(u){const b=x?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(x)}${g?"/"+escapeHtml(g):""}]</span>`:"";return`${escapeHtml(u)}${b}`}return v?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(v)}</span>`:N.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}B?.addEventListener("click",()=>{w?.classList.add("show"),O(!1)}),wireModal(w,A),S?.addEventListener("click",()=>O(!1)),P?.addEventListener("change",()=>O(!1)),M?.addEventListener("change",()=>O(!1));let j;[D,h].forEach(N=>{N?.addEventListener("change",()=>{clearTimeout(j),j=setTimeout(()=>O(!1),250)})}),f?.addEventListener("click",()=>O(!0)),z?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const R=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
</div>`);const f=document.getElementById("audit-modal"),I=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),E=document.getElementById("audit-refresh-btn"),M=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),D=document.getElementById("audit-load-more");let v=0;const B=50;async function x($){try{$||(v=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=P.value;let T=`/api/v1/audit-logs?limit=${B}&offset=${v}`;k&&(T+=`&action=${encodeURIComponent(k)}`);const L=await(await fetch(T)).json(),j=L.success&&L.entries?L.entries:[];if(j.length===0&&!$){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',D.style.display="none";return}let C="";$||(C='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',C+='<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>');for(const N of j){const R=N.outcome==="success";C+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',C+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(N.timestamp)}</td>`,C+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(N.ip||"-")}</td>`,C+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(N.action||"-")}</td>`,C+=`<td style="padding: 6px;">${escapeHtml(N.resource||"-")}</td>`,C+=`<td style="padding: 6px;"><span style="color: ${R?"var(--ok-fg)":"var(--bad-fg)"};">${R?"\u2713":"\u2717"}</span></td>`,C+="</tr>",N.details&&Object.keys(N.details).length>0&&(C+=`<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(N.details,null,2))}</pre></td></tr>`)}if(!$)C+="</table>",z.innerHTML=C;else{const N=z.querySelector("table");N&&N.insertAdjacentHTML("beforeend",C)}v+=j.length,D.style.display=j.length>=B?"":"none",z.querySelectorAll(".audit-row").forEach(N=>{N.dataset.wired||(N.dataset.wired="true",N.addEventListener("click",()=>{const R=N.nextElementSibling;R&&R.classList.contains("audit-detail")&&(R.style.display=R.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}I?.addEventListener("click",()=>{f?.classList.add("show"),x(!1)}),wireModal(f,A),E?.addEventListener("click",()=>x(!1)),P?.addEventListener("change",()=>x(!1)),D?.addEventListener("click",()=>x(!0)),M?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?x(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch($){showNotification("Error: "+$.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
<h3>\u{1F6E1}\uFE0F Security Center</h3>
<p class="modal-subtitle">
@@ -1863,39 +1771,39 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="sec-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("security-modal"),B=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),S=w.querySelectorAll(".sec-tab"),z=w.querySelectorAll(".sec-panel");let P=[],M=[],D=null;S.forEach(s=>{s.addEventListener("click",()=>{S.forEach(p=>p.classList.toggle("active",p===s)),z.forEach(p=>p.style.display=p.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&H(),s.dataset.tab==="events"&&j(),s.dataset.tab==="hosts"&&v()})}),B&&B.addEventListener("click",()=>{w.classList.add("show"),H(),T()}),A.addEventListener("click",h),w.addEventListener("click",s=>{s.target===w&&h()});function h(){w.classList.remove("show"),f()}function T(){if(f(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",s=>{try{P=JSON.parse(s.data).events||[],N()}catch{}}),D.addEventListener("security",s=>{try{const p=JSON.parse(s.data);P.unshift(p),P.length>500&&(P.length=500);const l=w.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?N():l==="overview"&&H()}catch{}}),D.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function f(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{w.classList.contains("show")&&T()});async function H(){try{const s=new Date(Date.now()-864e5).toISOString(),[p,l,m]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),c=(await p.json()).data||{},t=(await l.json()).data?.hosts||[],e=(await m.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${c.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${c.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${c.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",c.top_actors||[]),k("sec-top-targets",c.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function k(s,p){const l=document.getElementById(s);if(!p.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+p.map(m=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(m.key))}</td><td style="text-align:right;color:var(--muted);">${m.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),E=document.getElementById("sec-filter-severity"),I=document.getElementById("sec-filter-host"),O=document.getElementById("sec-filter-actor"),C=document.getElementById("sec-refresh-btn");[$,E,I].forEach(s=>s.addEventListener("change",j)),O.addEventListener("input",b(j,250)),C.addEventListener("click",j);async function j(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),E.value&&s.set("severity",E.value),I.value&&s.set("source_host",I.value),O.value&&s.set("actor_prefix",O.value),P=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],N(),(!I.options.length||I.options.length===1)&&await u()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(s.message)+"</div>"}}function N(){const s=document.getElementById("sec-events-container");if(!P.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=P.slice(0,200).map(R).join("")}function R(s){const p=s.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[p]||"#7f8c8d",m=s.ts?new Date(s.ts).toLocaleTimeString():"",c=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",a=s.action||"",o=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${l};font-weight:600;">${g(p)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(c)}</span>
</div>`);const f=document.getElementById("security-modal"),I=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),E=f.querySelectorAll(".sec-tab"),M=f.querySelectorAll(".sec-panel");let P=[],z=[],D=null;E.forEach(r=>{r.addEventListener("click",()=>{E.forEach(c=>c.classList.toggle("active",c===r)),M.forEach(c=>c.style.display=c.dataset.panel===r.dataset.tab?"":"none"),r.dataset.tab==="overview"&&$(),r.dataset.tab==="events"&&N(),r.dataset.tab==="hosts"&&m()})}),I&&I.addEventListener("click",()=>{f.classList.add("show"),$(),B()}),A.addEventListener("click",v),f.addEventListener("click",r=>{r.target===f&&v()});function v(){f.classList.remove("show"),x()}function B(){if(x(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",r=>{try{P=JSON.parse(r.data).events||[],R()}catch{}}),D.addEventListener("security",r=>{try{const c=JSON.parse(r.data);P.unshift(c),P.length>500&&(P.length=500);const d=f.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?R():d==="overview"&&$()}catch{}}),D.onerror=()=>{}}catch(r){console.warn("[security] SSE failed:",r.message)}}function x(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{f.classList.contains("show")&&B()});async function $(){try{const r=new Date(Date.now()-864e5).toISOString(),[c,d,b]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(r)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(r)}`)]),y=(await c.json()).data||{},t=(await d.json()).data?.hosts||[],e=(await b.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${y.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${y.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${y.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",y.top_actors||[]),k("sec-top-targets",y.top_targets||[])}catch(r){console.warn("[security] refreshOverview failed:",r.message)}}function k(r,c){const d=document.getElementById(r);if(!c.length){d.innerHTML='<div class="panel-empty">No data</div>';return}d.innerHTML='<table style="width:100%;font-size:0.85rem;">'+c.map(b=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(b.key))}</td><td style="text-align:right;color:var(--muted);">${b.count}</td></tr>`).join("")+"</table>"}const T=document.getElementById("sec-filter-source"),S=document.getElementById("sec-filter-severity"),L=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),C=document.getElementById("sec-refresh-btn");[T,S,L].forEach(r=>r.addEventListener("change",N)),j.addEventListener("input",H(N,250)),C.addEventListener("click",N);async function N(){try{const r=new URLSearchParams;r.set("limit","200"),T.value&&r.set("source_type",T.value),S.value&&r.set("severity",S.value),L.value&&r.set("source_host",L.value),j.value&&r.set("actor_prefix",j.value),P=(await(await fetch(`/api/v1/security/events?${r}`)).json()).data.events||[],R(),(!L.options.length||L.options.length===1)&&await u()}catch(r){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}function R(){const r=document.getElementById("sec-events-container");if(!P.length){r.innerHTML='<div class="panel-empty">No events</div>';return}r.innerHTML=P.slice(0,200).map(U).join("")}function U(r){const c=r.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",b=r.ts?new Date(r.ts).toLocaleTimeString():"",y=r.source_type||"",t=r.actor||"\u2014",e=r.target||"",a=r.action||"",o=r.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${d};font-weight:600;">${g(c)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(y)}</span>
<span title="${g(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(t)}</span>
<span title="${g(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(a)} ${g(e)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(o)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(m)}</span>
</div>`}async function u(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=I.value;I.innerHTML='<option value="">All hosts</option>'+p.map(m=>`<option value="${g(m.id)}">${g(m.label||m.id)}</option>`).join(""),l&&(I.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",x),document.getElementById("sec-hosts-refresh").addEventListener("click",v);async function v(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];M=p;const l=document.getElementById("sec-hosts-container");if(!p.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=p.map(m=>{const c=m.enabled?m.last_seen_at?Date.now()-Date.parse(m.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(b)}</span>
</div>`}async function u(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=L.value;L.innerHTML='<option value="">All hosts</option>'+c.map(b=>`<option value="${g(b.id)}">${g(b.label||b.id)}</option>`).join(""),d&&(L.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",h),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=c;const d=document.getElementById("sec-hosts-container");if(!c.length){d.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}d.innerHTML=c.map(b=>{const y=b.enabled?b.last_seen_at?Date.now()-Date.parse(b.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${g(m.label||m.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(m.type)}</span>
<strong>${g(b.label||b.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(b.type)}</span>
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
id: ${g(m.id)} \xB7
registered ${new Date(m.registered_at).toLocaleDateString()} \xB7
last seen ${m.last_seen_at?new Date(m.last_seen_at).toLocaleString():"never"}
id: ${g(b.id)} \xB7
registered ${new Date(b.registered_at).toLocaleDateString()} \xB7
last seen ${b.last_seen_at?new Date(b.last_seen_at).toLocaleString():"never"}
</div>
</div>
<div>
<span style="font-size:0.85rem;margin-right:12px;">${c}</span>
${m.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${g(m.id)}" style="color:var(--bad-fg);">Remove</button>`}
<span style="font-size:0.85rem;margin-right:12px;">${y}</span>
${b.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${g(b.id)}" style="color:var(--bad-fg);">Remove</button>`}
</div>
</div>`}).join(""),l.querySelectorAll(".sec-host-del").forEach(m=>{m.addEventListener("click",async()=>{confirm(`Remove host ${m.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(m.dataset.id)}`,{method:"DELETE"}),v())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+g(s.message)+"</div>"}}async function x(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const p=prompt("Display label:",s)||s,l=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const m=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:p,type:l})}),c=await m.json();if(!m.ok){alert("Failed: "+(c?.error?.message||m.statusText));return}alert(`\u2705 Host registered!
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(b=>{b.addEventListener("click",async()=>{confirm(`Remove host ${b.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(b.dataset.id)}`,{method:"DELETE"}),m())})})}catch(r){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}async function h(){const r=prompt("Host id (lowercase, no spaces):");if(!r)return;const c=prompt("Display label:",r)||r,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const b=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:r,label:c,type:d})}),y=await b.json();if(!b.ok){alert("Failed: "+(y?.error?.message||b.statusText));return}alert(`\u2705 Host registered!
id: ${c.data.host.id}
label: ${c.data.host.label}
type: ${c.data.host.type}
id: ${y.data.host.id}
label: ${y.data.host.label}
type: ${y.data.host.type}
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
${c.data.api_key}
${y.data.api_key}
Send this key as: Authorization: Bearer <api_key>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m){alert("Failed: "+m.message)}}function g(s){return String(s).replace(/[&<>"']/g,p=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[p])}function b(s,p){let l;return function(){clearTimeout(l),l=setTimeout(()=>s.apply(this,arguments),p)}}})(),(function(){const w=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b){alert("Failed: "+b.message)}}function g(r){return String(r).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function H(r,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>r.apply(this,arguments),c)}}})(),(function(){const f=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
<label for="weather-location-input">Location:</label>
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -1906,23 +1814,23 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label>
</div>
</div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const B="weather-location",A="weather-zip",S="weather-geo",z="weather-unit";!safeGet(B)&&safeGet(A)&&safeSet(B,safeGet(A));function P(){return safeGet(z)||"imperial"}function M(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},h={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function f(C){return T[Math.round(C/22.5)%16]}async function H(C){const j=safeGet(S);if(j)try{const x=JSON.parse(j);if(x.query===C)return x}catch{}const N=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(C)}&count=1&language=en&format=json`);if(!N.ok)throw new Error("Geocoding failed");const R=await N.json();if(!R.results||!R.results.length)throw new Error("Location not found");const u=R.results[0],v={query:C,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(S,JSON.stringify(v)),v}function k(C){return C.countryCode==="US"&&C.state?`${C.city}, ${C.state}`:C.country?`${C.city}, ${C.country}`:C.city}async function $(C){try{const j=await H(C),N=P(),R=N==="metric"?"celsius":"fahrenheit",u=N==="metric"?"kmh":"mph",v=`https://api.open-meteo.com/v1/forecast?latitude=${j.lat}&longitude=${j.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${R}&wind_speed_unit=${u}`,x=await fetch(v);if(!x.ok)throw new Error("Weather fetch failed");const b=(await x.json()).current,s=b.weather_code;return{temp:Math.round(b.temperature_2m),condition:D[s]||"Unknown",icon:h[s]||"\u{1F324}\uFE0F",locationStr:k(j),windSpeed:Math.round(b.wind_speed_10m),windDir:f(b.wind_direction_10m),unit:N}}catch(j){return console.warn("Weather fetch failed:",j),null}}async function E(){const C=M();if(!C.icon||!C.temp||!C.condition||!C.location||!C.wind){console.warn("Weather widget elements not found");return}const j=safeGet(B);if(!j){C.location.textContent="Set Location",C.temp.textContent="--\xB0",C.condition.textContent="Click \u2699\uFE0F to configure",C.wind.textContent="--",C.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const N=await $(j);if(N){const R=N.unit==="metric"?"\xB0C":"\xB0F",u=N.unit==="metric"?"km/h":"mph";C.location.textContent=N.locationStr,C.temp.textContent=`${N.temp}${R}`,C.condition.textContent=N.condition,C.wind.textContent=`Wind: ${N.windSpeed} ${u} ${N.windDir}`,C.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(N.icon)}</span>`}}catch(N){w.logError("[Weather] Update Error",N,{function:"updateWeather"}),C.location.textContent="Weather Error",C.temp.textContent="Error",C.condition.textContent="Failed to load",C.wind.textContent="--"}}const I=document.getElementById("weather-modal"),O=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{O.value=safeGet(B)||"";const C=P(),j=I.querySelector(`input[name="weather-unit-radio"][value="${C}"]`);j&&(j.checked=!0),I.classList.add("show"),O.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{I.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const C=O.value.trim();if(C){safeGet(B)!==C&&safeSet(S,""),safeSet(B,C);const N=I.querySelector('input[name="weather-unit-radio"]:checked'),R=N?N.value:"imperial",u=P();safeSet(z,R),u!==R&&safeSet(S,""),I.classList.remove("show"),E()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(I),document.addEventListener("keydown",C=>{C.keyLine truncated
<circle cx="${r}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const I="weather-location",A="weather-zip",E="weather-geo",M="weather-unit";!safeGet(I)&&safeGet(A)&&safeSet(I,safeGet(A));function P(){return safeGet(M)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},B=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function x(C){return B[Math.round(C/22.5)%16]}async function $(C){const N=safeGet(E);if(N)try{const h=JSON.parse(N);if(h.query===C)return h}catch{}const R=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(C)}&count=1&language=en&format=json`);if(!R.ok)throw new Error("Geocoding failed");const U=await R.json();if(!U.results||!U.results.length)throw new Error("Location not found");const u=U.results[0],m={query:C,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(E,JSON.stringify(m)),m}function k(C){return C.countryCode==="US"&&C.state?`${C.city}, ${C.state}`:C.country?`${C.city}, ${C.country}`:C.city}async function T(C){try{const N=await $(C),R=P(),U=R==="metric"?"celsius":"fahrenheit",u=R==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${N.lat}&longitude=${N.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${U}&wind_speed_unit=${u}`,h=await fetch(m);if(!h.ok)throw new Error("Weather fetch failed");const H=(await h.json()).current,r=H.weather_code;return{temp:Math.round(H.temperature_2m),condition:D[r]||"Unknown",icon:v[r]||"\u{1F324}\uFE0F",locationStr:k(N),windSpeed:Math.round(H.wind_speed_10m),windDir:x(H.wind_direction_10m),unit:R}}catch(N){return console.warn("Weather fetch failed:",N),null}}async function S(){const C=z();if(!C.icon||!C.temp||!C.condition||!C.location||!C.wind){console.warn("Weather widget elements not found");return}const N=safeGet(I);if(!N){C.location.textContent="Set Location",C.temp.textContent="--\xB0",C.condition.textContent="Click \u2699\uFE0F to configure",C.wind.textContent="--",C.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const R=await T(N);if(R){const U=R.unit==="metric"?"\xB0C":"\xB0F",u=R.unit==="metric"?"km/h":"mph";C.location.textContent=R.locationStr,C.temp.textContent=`${R.temp}${U}`,C.condition.textContent=R.condition,C.wind.textContent=`Wind: ${R.windSpeed} ${u} ${R.windDir}`,C.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(R.icon)}</span>`}}catch(R){f.logError("[Weather] Update Error",R,{function:"updateWeather"}),C.location.textContent="Weather Error",C.temp.textContent="Error",C.condition.textContent="Failed to load",C.wind.textContent="--"}}const L=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(I)||"";const C=P(),N=L.querySelector(`input[name="weather-unit-radio"][value="${C}"]`);N&&(N.checked=!0),L.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const C=j.value.trim();if(C){safeGet(I)!==C&&safeSet(E,""),safeSet(I,C);const R=L.querySelector('input[name="weather-unit-radio"]:checked'),U=R?R.value:"imperial",u=P();safeSet(M,U),u!==U&&safeSet(E,""),L.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(L),document.addEventListener("keydown",C=>{C.keyLine truncated
<circle cx="${s}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F}
${q}
<line x1="${r}" y1="${d}" x2="${r+28*Math.cos(U*Math.PI/180)}" y2="${d+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${r}" y1="${d}" x2="${r+38*Math.cos(L*Math.PI/180)}" y2="${d+38*Math.sin(L*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${r}" y1="${d}" x2="${r+42*Math.cos(y*Math.PI/180)}" y2="${d+42*Math.sin(y*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${r}" cy="${d}" r="3" fill="var(--fg)"/>
</svg>`,J=t.getHours()>=12?"PM":"AM";B.innerHTML=`<div class="analog-clock-wrap">${_}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(t)}</span></div></div>`,T="analog"}function v(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(w.className!==i&&(w.className=i),P){case"lcd":C(t);break;case"lcd-blue":C(t);break;case"lcd-amber":C(t);break;case"lcd-retro":C(t);break;case"lcd-taxi":C(t);break;case"flip":j(t);break;case"binary":R(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:O(t)}a===0&&o===0&&e!==M&&(M=e,k(e)),a!==0&&(M=-1)}function x(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{v(),x()},e)}document.addEventListener("visibilitychange",()=>{h="",E(),v(),x()}),v(),x();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let b='<div class="clock-style-grid">';g.forEach(t=>{b+=`<label class="clock-style-option">
${_}
<line x1="${s}" y1="${l}" x2="${s+28*Math.cos(O*Math.PI/180)}" y2="${l+28*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+38*Math.cos(w*Math.PI/180)}" y2="${l+38*Math.sin(w*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(p*Math.PI/180)}" y2="${l+42*Math.sin(p*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${s}" cy="${l}" r="3" fill="var(--fg)"/>
</svg>`,J=t.getHours()>=12?"PM":"AM";I.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${T(t)}</span></div></div>`,B="analog"}function m(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(f.className!==i&&(f.className=i),P){case"lcd":C(t);break;case"lcd-blue":C(t);break;case"lcd-amber":C(t);break;case"lcd-retro":C(t);break;case"lcd-taxi":C(t);break;case"flip":N(t);break;case"binary":U(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:j(t)}a===0&&o===0&&e!==z&&(z=e,k(e)),a!==0&&(z=-1)}function h(){clearTimeout($);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;$=setTimeout(()=>{m(),h()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),m(),h()}),m(),h();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let H='<div class="clock-style-grid">';g.forEach(t=>{H+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${t.id}">
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
</label>`}),b+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
</label>`}),H+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="max-width: 420px;">
<h3>Clock Settings</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${b}
${H}
</div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -1946,7 +1854,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<button id="clock-settings-save">Save</button>
</div>
</div>
</div>`);const s=document.getElementById("clock-settings-modal"),p=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),m=document.getElementById("clock-volume-section");function c(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),p.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",m.style.opacity=p.checked?"1":"0.4"}p?.addEventListener("change",()=>{m.style.opacity=p.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{c(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(p.checked)),safeSet("clock-chime-volume",l.value),P=e,h="",E(),v(),x(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,h="",E(),v()})})})(),(function(){async function w(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[h,T]of Object.entries(D.status)){const f=document.getElementById("uptime-"+h),H=document.getElementById("uptime-bar-"+h);if(!f)continue;const k=T.uptime?.["24h"];if(k!=null){const $=k.toFixed(1);f.textContent=`${$}% uptime`,f.className="uptime-chip",k>=99.9?f.classList.add("excellent"):k>=99?f.classList.add("good"):k>=95?f.classList.add("degraded"):f.classList.add("poor"),H&&(H.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let B;try{B=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{B=new Set}let A=[];async function S(M){try{const h=await(await fetch("/api/v1/updates/available")).json();if(!h.success)return;document.querySelectorAll(".update-available-badge").forEach(f=>f.classList.remove("visible"));const T=h.updates||[];if(A=T,M&&T.length>0){const f=window._lastKnownUpdateCount||0;f>0&&T.length>f&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const f of T){const H=window.APPS||[];for(const k of H)if(k.containerId===f.containerId||k.id===f.containerName||k.name===f.containerName){if(B.has(k.id))break;const $=document.getElementById("update-badge-"+k.id),E=document.getElementById("update-btn-"+k.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=I=>{I.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),E&&(E.style.background="#f97316",E.style.borderColor="#f97316",E.style.boxShadow="0 0 6px #f9731688",E.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function z(){setTimeout(()=>{w(),S()},5e3),setInterval(()=>{w(),S(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(w,1e3)}catch(M){console.warn("[Card Badges] Error in refreshAll hook:",M.message)}}),z()})(),(function(){var w=null,B=null,A={},S={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},z=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var M=document.getElementById("theme-label");function D(o){if(S[o])return S[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function h(){M&&(M.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),r=o[(n+1)%o.length];window.applyTheme(r),h()}),h();function T(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),b=document.getElementById("clock-volume-section");function y(){const t=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",b.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{b.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(d.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=r.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),P=e,v="",S(),m(),h(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,v="",S(),m()})})})(),(function(){async function f(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[v,B]of Object.entries(D.status)){const x=document.getElementById("uptime-"+v),$=document.getElementById("uptime-bar-"+v);if(!x)continue;const k=B.uptime?.["24h"];if(k!=null){const T=k.toFixed(1);x.textContent=`${T}% uptime`,x.className="uptime-chip",k>=99.9?x.classList.add("excellent"):k>=99?x.classList.add("good"):k>=95?x.classList.add("degraded"):x.classList.add("poor"),$&&($.style.width=T+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let I;try{I=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{I=new Set}let A=[];async function E(z){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(x=>x.classList.remove("visible"));const B=v.updates||[];if(A=B,z&&B.length>0){const x=window._lastKnownUpdateCount||0;x>0&&B.length>x&&showNotification(`${B.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=B.length}if(!B.length)return;for(const x of B){const $=window.APPS||[];for(const k of $)if(k.containerId===x.containerId||k.id===x.containerName||k.name===x.containerName){if(I.has(k.id))break;const T=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);T&&(T.classList.add("visible"),T.title="Update available \u2014 click to open Update Management.",T.style.cursor="pointer",T.onclick=L=>{L.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),S&&(S.style.background="#f97316",S.style.borderColor="#f97316",S.style.boxShadow="0 0 6px #f9731688",S.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function M(){setTimeout(()=>{f(),E()},5e3),setInterval(()=>{f(),E(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(f,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),M()})(),(function(){var f=null,I=null,A={},E={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},M=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var z=document.getElementById("theme-label");function D(o){if(E[o])return E[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function v(){z&&(z.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),s=o[(n+1)%o.length];window.applyTheme(s),v()}),v();function B(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -1983,16 +1891,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<button id="license-activate" class="btn-accent">Activate</button>
</div>
</div>
</div>`);const w=document.getElementById("license-modal"),B=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),S=document.getElementById("license-deactivate"),z=document.getElementById("license-error"),P=document.getElementById("license-success"),M=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),h=document.getElementById("license-badge"),T=document.getElementById("license-details"),f=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let k=null;function $(){z.style.display="none",P.style.display="none"}function E(x){$(),z.textContent=x,z.style.display="block"}function I(x){$(),P.textContent=x,P.style.display="block"}function O(x){if(k=x,x.active){h.style.background="rgba(46,204,113,0.15)",h.style.color="var(--ok-fg)",M.textContent="\u2605",D.textContent="Premium Active";const s=x.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(x.expiresAt).toLocaleDateString()}</strong> (${x.daysRemaining} days remaining)</div>`;T.innerHTML=`
<div>Code: <code style="font-family: monospace;">${x.code||"***"}</code></div>
${s}
`,H.style.display="none",A.style.display="none",S.style.display=""}else h.style.background="rgba(149,165,166,0.15)",h.style.color="var(--muted)",M.textContent="\u2606",D.textContent=x.expired?"License Expired":"Free Tier",T.innerHTML=x.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",A.style.display="",S.style.display="none";const g=x.premiumFeatures||{},b=new Set(x.features||[]);f.innerHTML=Object.entries(g).map(([s,p])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${b.has(s)?"\u2705":"\u{1F512}"}</span>
</div>`);const f=document.getElementById("license-modal"),I=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),E=document.getElementById("license-deactivate"),M=document.getElementById("license-error"),P=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),B=document.getElementById("license-details"),x=document.getElementById("license-feature-list"),$=document.getElementById("license-activate-section");let k=null;function T(){M.style.display="none",P.style.display="none"}function S(h){T(),M.textContent=h,M.style.display="block"}function L(h){T(),P.textContent=h,P.style.display="block"}function j(h){if(k=h,h.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",z.textContent="\u2605",D.textContent="Premium Active";const r=h.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(h.expiresAt).toLocaleDateString()}</strong> (${h.daysRemaining} days remaining)</div>`;B.innerHTML=`
<div>Code: <code style="font-family: monospace;">${h.code||"***"}</code></div>
${r}
`,$.style.display="none",A.style.display="none",E.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",z.textContent="\u2606",D.textContent=h.expired?"License Expired":"Free Tier",B.innerHTML=h.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",$.style.display="",A.style.display="",E.style.display="none";const g=h.premiumFeatures||{},H=new Set(h.features||[]);x.innerHTML=Object.entries(g).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${H.has(r)?"\u2705":"\u{1F512}"}</span>
<div>
<div style="font-weight: 600; font-size: 0.9rem;">${p.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${p.description}</div>
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
</div>
</div>`).join("")}async function C(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(O(g.license),R(g.license))}catch(x){console.warn("Failed to load license status:",x.message)}}async function j(){const x=B.value.trim();if(!x){E("Please enter a license code.");return}$(),A.disabled=!0,A.textContent="Activating...";try{const b=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:x})})).json();b.success?(I(b.message),B.value="",O(b.license),showNotification("License activated! Premium features unlocked.","success",5e3),R(b.license)):E(b.error||"Activation failed")}catch(g){E("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function N(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){S.disabled=!0,S.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(I(g.message),await C(),showNotification("License deactivated.","info",3e3),R({active:!1})):E(g.error||"Deactivation failed")}catch(x){E("Network error: "+x.message)}finally{S.disabled=!1,S.textContent="Deactivate"}}}function R(x){const g=document.getElementById("license-status-topbar"),b=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),p=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(x.active?"premium":"free"),x.active)if(b.textContent="\u2605",s.textContent="PREMIUM",x.lifetime)p.textContent="\xB7 LIFETIME";else{const l=x.daysRemaining;p.textContent=l!=null?"\xB7 "+l+"d remaining":""}else b.textContent="\u2606",s.textContent=x.expired?"EXPIRED":"FREE TIER",p.textContent=""}function u(){$(),C(),w.classList.add("show")}B.addEventListener("input",function(){let x=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(x.length>this._prevLength&&(x=x.replace(/-/g,""),x.length>2&&!x.startsWith("DC")&&(x="DC"+x),x.startsWith("DC")&&x.length>2)){const g=["DC"],b=x.substring(2);for(let s=0;s<b.length;s+=5)g.push(b.substring(s,s+5));x=g.join("-")}this._prevLength=x.length,this.value=x}),A.addEventListener("click",j),S.addEventListener("click",N),B.addEventListener("keydown",x=>{x.key==="Enter"&&j()}),wireModal(w,document.getElementById("license-cancel"));const v=document.getElementById("license-status-topbar");v&&v.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(x){try{return(await(await fetch(`/api/v1/license/feature/${x}`)).json()).available}catch{return!1}},C().then(x=>{k&&R(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const w=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
</div>`).join("")}async function C(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(j(g.license),U(g.license))}catch(h){console.warn("Failed to load license status:",h.message)}}async function N(){const h=I.value.trim();if(!h){S("Please enter a license code.");return}T(),A.disabled=!0,A.textContent="Activating...";try{const H=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();H.success?(L(H.message),I.value="",j(H.license),showNotification("License activated! Premium features unlocked.","success",5e3),U(H.license)):S(H.error||"Activation failed")}catch(g){S("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function R(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){E.disabled=!0,E.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(L(g.message),await C(),showNotification("License deactivated.","info",3e3),U({active:!1})):S(g.error||"Deactivation failed")}catch(h){S("Network error: "+h.message)}finally{E.disabled=!1,E.textContent="Deactivate"}}}function U(h){const g=document.getElementById("license-status-topbar"),H=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(h.active?"premium":"free"),h.active)if(H.textContent="\u2605",r.textContent="PREMIUM",h.lifetime)c.textContent="\xB7 LIFETIME";else{const d=h.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else H.textContent="\u2606",r.textContent=h.expired?"EXPIRED":"FREE TIER",c.textContent=""}function u(){T(),C(),f.classList.add("show")}I.addEventListener("input",function(){let h=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(h.length>this._prevLength&&(h=h.replace(/-/g,""),h.length>2&&!h.startsWith("DC")&&(h="DC"+h),h.startsWith("DC")&&h.length>2)){const g=["DC"],H=h.substring(2);for(let r=0;r<H.length;r+=5)g.push(H.substring(r,r+5));h=g.join("-")}this._prevLength=h.length,this.value=h}),A.addEventListener("click",N),E.addEventListener("click",R),I.addEventListener("keydown",h=>{h.key==="Enter"&&N()}),wireModal(f,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(h){try{return(await(await fetch(`/api/v1/license/feature/${h}`)).json()).available}catch{return!1}},C().then(h=>{k&&U(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const f=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
@@ -2014,7 +1922,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<div class="share-tab-panel" data-panel="public">
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
${w.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
${f.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
</select>
<button id="share-public-create" class="btn-accent" type="button"
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
@@ -2061,17 +1969,17 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
</div>
</div>
</div>
`);const B=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),S=document.getElementById("share-issued"),z=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),M=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),h=document.getElementById("share-success"),T=document.getElementById("share-outstanding-list"),f=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),$=document.getElementById("share-ts-email"),E=document.getElementById("share-public-ttl");let I=null,O="public";function C(){D.style.display="none",h.style.display="none",S.style.display="none"}function j(t){h.style.display="none",S.style.display="none",D.textContent=t,D.style.display="block"}function N(t){D.style.display="none",h.textContent=t,h.style.display="block"}function R(){return window.location.origin}function u(t){return t.startsWith("http")?t:R()+t}function v(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function x(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){O=t,C(),B.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),B.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function b(){T.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){T.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===I.id);if(o.length===0){T.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}T.innerHTML=o.map(i=>{const n=i.expiresAt?x(new Date(i.expiresAt).getTime()-Date.now()):"";return`
`);const I=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),E=document.getElementById("share-issued"),M=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),z=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),v=document.getElementById("share-success"),B=document.getElementById("share-outstanding-list"),x=document.getElementById("share-cancel"),$=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),T=document.getElementById("share-ts-email"),S=document.getElementById("share-public-ttl");let L=null,j="public";function C(){D.style.display="none",v.style.display="none",E.style.display="none"}function N(t){v.style.display="none",E.style.display="none",D.textContent=t,D.style.display="block"}function R(t){D.style.display="none",v.textContent=t,v.style.display="block"}function U(){return window.location.origin}function u(t){return t.startsWith("http")?t:U()+t}function m(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function h(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){j=t,C(),I.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),I.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function H(){B.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){B.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===L.id);if(o.length===0){B.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}B.innerHTML=o.map(i=>{const n=i.expiresAt?h(new Date(i.expiresAt).getTime()-Date.now()):"";return`
<div class="share-row" data-share-id="${escapeHtml(i.id)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;">
<strong>${i.kind==="tailscale"?"Tailscale":"Public"}</strong>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(v(i.expiresAt))} (${n})</span>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(i.expiresAt))} (${n})</span>
</span>
<button class="share-revoke" data-share-id="${escapeHtml(i.id)}" type="button"
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
Revoke
</button>
</div>
`}).join(""),T.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>s(i.dataset.shareId))})}catch{T.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));j(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}N("Share revoked."),b()}catch{j("Network error while revoking.")}}async function p(){if(!I)return;C(),H.disabled=!0;const t=H.textContent;H.textContent="Creating\u2026";try{const e=parseInt(E.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:I.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data.urlPath;z.value=u(i),M.textContent=`Public link \xB7 expires ${v(o.data.expiresAt)}`,S.style.display="block",N("Share link created."),b()}catch{j("Network error while creating share.")}finally{H.disabled=!1,H.textContent=t}}async function l(){if(!I)return;const t=($.value||"").trim();if(!t||!t.includes("@")){j("A valid recipient email is required.");return}C(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:I.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data&&o.data.urlPath;i?(z.value=u(i),M.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(z.value="",M.textContent=`Tailscale invite sent to ${t}.`),S.style.display="block",N("Tailscale invite created."),$.value="",b()}catch{j("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function m(t,e){const a=e&&e.error||"";if(t===402){j("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){j("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){j("You do not have permission to share services.");return}if(t===404){j("Service not found. It may have been deleted.");return}j(a||`Request failed (HTTP ${t}).`)}function c(t){!t||!t.id||(I={id:t.id,name:t.name||t.id},A.textContent=I.name,C(),g("public"),$.value="",E.value=String(w[1].ms),B.classList.add("show"),b())}B.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),H.addEventListener("click",p),k.addEventListener("click",l),f.addEventListener("click",()=>B.classList.remove("show")),wireModal(B,f),P.addEventListener("click",async()=>{const t=z.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(z.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{j("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=c})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
`}).join(""),B.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>r(i.dataset.shareId))})}catch{B.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function r(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));N(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}R("Share revoked."),H()}catch{N("Network error while revoking.")}}async function c(){if(!L)return;C(),$.disabled=!0;const t=$.textContent;$.textContent="Creating\u2026";try{const e=parseInt(S.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:L.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){b(a.status,o);return}const i=o.data.urlPath;M.value=u(i),z.textContent=`Public link \xB7 expires ${m(o.data.expiresAt)}`,E.style.display="block",R("Share link created."),H()}catch{N("Network error while creating share.")}finally{$.disabled=!1,$.textContent=t}}async function d(){if(!L)return;const t=(T.value||"").trim();if(!t||!t.includes("@")){N("A valid recipient email is required.");return}C(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:L.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){b(a.status,o);return}const i=o.data&&o.data.urlPath;i?(M.value=u(i),z.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(M.value="",z.textContent=`Tailscale invite sent to ${t}.`),E.style.display="block",R("Tailscale invite created."),T.value="",H()}catch{N("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function b(t,e){const a=e&&e.error||"";if(t===402){N("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){N("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){N("You do not have permission to share services.");return}if(t===404){N("Service not found. It may have been deleted.");return}N(a||`Request failed (HTTP ${t}).`)}function y(t){!t||!t.id||(L={id:t.id,name:t.name||t.id},A.textContent=L.name,C(),g("public"),T.value="",S.value=String(f[1].ms),I.classList.add("show"),H())}I.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),$.addEventListener("click",c),k.addEventListener("click",d),x.addEventListener("click",()=>I.classList.remove("show")),wireModal(I,x),P.addEventListener("click",async()=>{const t=M.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(M.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{N("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=y})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
+1 -1
View File
@@ -203,7 +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>
<button id="view-journald-logs" aria-label="Host journald logs">🛰️ Host 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>
+115 -263
View File
@@ -1,292 +1,144 @@
// ========== ERROR LOG VIEWER (DC-052) ==========
// DC-052: Adds Level / Context / Search / Time-range filters, server-side
// pagination with Load More, click-to-expand stack frames, and a distinct
// contexts dropdown backed by /api/v1/error-logs/contexts. Mirrors the
// audit-log UX (DC-050) so operators can drill into a subsystem as easily
// as they can audit who-did-what.
// ========== 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. Same weather-modal shell as audit-log so styles
// are shared; wider min-width because error stacks need room to breathe.
injectModal('error-log-modal', `<div id="error-log-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>📋 Error Logs</h3>
<p class="modal-subtitle">
Errors and warnings from the DashCaddy API. Click a row to see the full stack trace.
</p>
const MAX_LEVELS = ['ERR', 'WRN', 'INF', 'DBG'];
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
<label class="text-muted-sm">Level:</label>
<select id="error-log-level" 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</option>
<option value="ERR">Errors</option>
<option value="WARN">Warnings</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
</select>
<label class="text-muted-sm">Context:</label>
<select id="error-log-context" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem; max-width: 220px;">
<option value="">All</option>
</select>
<label class="text-muted-sm" style="margin-left: 8px;">Search:</label>
<input id="error-log-search" type="search" placeholder="message / stack / ip" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem; min-width: 180px;">
<label class="text-muted-sm" style="margin-left: 8px;">Since:</label>
<input id="error-log-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="error-log-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="error-log-refresh" class="btn-sm">🔄 Refresh</button>
<span style="flex: 1;"></span>
<button id="error-log-clear" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑️ Clear Log</button>
</div>
<div id="error-log-container" class="scroll-container">
<div class="panel-empty"><span class="brand-spinner"></span> Loading error logs...</div>
</div>
<div style="margin-top: 12px; text-align: center;">
<button id="error-log-load-more" style="display: none; padding: 6px 16px; font-size: 0.8rem;">Load More</button>
</div>
<div style="margin-top: 8px; font-size: 0.78rem; color: var(--muted); text-align: right;">
<span id="error-log-total"></span>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="error-log-close">Close</button>
</div>
</div>
</div>`);
injectModal('error-log-modal', [
'<div id="error-log-modal" class="logs-modal">',
' <div class="logs-modal-content">',
' <div class="logs-header">',
' <h3>📋 Error Logs</h3>',
' <div class="logs-controls">',
' <select id="error-log-level" aria-label="Filter by level" style="padding:4px 8px!important;font-size:.85rem!important">',
' <option value="">All levels</option>',
' <option value="ERR">Errors</option>',
' <option value="WRN">Warnings</option>',
' <option value="INF">Info</option>',
' <option value="DBG">Debug</option>',
' </select>',
' <select id="error-log-tail" aria-label="Tail length" style="padding:4px 8px!important;font-size:.85rem!important">',
' <option value="50">Last 50</option>',
' <option value="100" selected>Last 100</option>',
' <option value="200">Last 200</option>',
' <option value="500">Last 500</option>',
' </select>',
' <button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">🔄 Refresh</button>',
' <button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">🗑️ Clear</button>',
' <button id="error-log-close" class="close-btn">✕</button>',
' </div>',
' </div>',
' <div class="logs-container">',
' <div id="error-log-meta" class="logs-meta" style="padding:6px 12px;color:var(--muted);font-size:.8rem;border-bottom:1px solid var(--border)"></div>',
' <div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div>',
' </div>',
' </div>',
'</div>',
].join(''));
const modal = document.getElementById('error-log-modal');
const 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 levelSel = document.getElementById('error-log-level');
const contextSel = document.getElementById('error-log-context');
const searchInput = document.getElementById('error-log-search');
const sinceInput = document.getElementById('error-log-since');
const untilInput = document.getElementById('error-log-until');
const container = document.getElementById('error-log-container');
const loadMoreBtn = document.getElementById('error-log-load-more');
const totalSpan = document.getElementById('error-log-total');
const levelSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-level'));
const tailSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-tail'));
const PAGE_SIZE = 50;
let currentOffset = 0;
let inflight = null;
let filterNonce = 0;
// Cached distinct contexts so the dropdown is populated once per open and
// re-populated after a clear (which removes all contexts) or a refresh
// that surfaces a new subsystem for the first time.
let knownContexts = [];
// datetime-local fields are naive local time — convert to UTC ISO so the
// server compares correctly. Same shape as audit-log.js so the operator
// sees consistent behaviour between the two modals.
function toIso(localDtValue) {
if (!localDtValue) return null;
const d = new Date(localDtValue);
if (isNaN(d.getTime())) return null;
return d.toISOString();
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';
}
// Pull the distinct contexts list once per open. Failures are silent
// (the dropdown will just show "All" only) so a transient backend hiccup
// doesn't block the operator from seeing the actual error rows.
async function refreshContexts() {
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 res = await fetch('/api/v1/error-logs/contexts');
if (!res.ok) return;
const data = await res.json();
if (!data.success || !Array.isArray(data.contexts)) return;
knownContexts = data.contexts;
const currentValue = contextSel.value;
contextSel.innerHTML = '<option value="">All</option>';
for (const c of data.contexts) {
const opt = document.createElement('option');
opt.value = c.name;
opt.textContent = `${c.name} (${c.count})`;
contextSel.appendChild(opt);
}
// Restore previous selection if still present.
if (currentValue && data.contexts.some((c) => c.name === currentValue)) {
contextSel.value = currentValue;
}
} catch { /* ignore */ }
}
const response = await fetch('/api/v1/error-logs?' + qs);
const data = await response.json();
function buildQuery() {
const params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(currentOffset));
if (levelSel.value) params.set('level', levelSel.value);
if (contextSel.value) params.set('context', contextSel.value);
const since = toIso(sinceInput.value);
const until = toIso(untilInput.value);
if (since) params.set('since', since);
if (until) params.set('until', until);
const search = (searchInput.value || '').trim();
if (search) params.set('search', search);
return params;
}
async function loadLogs(append) {
try {
if (!append) {
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 = buildQuery();
const res = await fetch('/api/v1/error-logs?' + params.toString(), {
signal: inflight.signal,
});
// Mirror audit-log: surface 4xx/5xx explicitly instead of falling
// through to a misleading "no entries yet" empty state.
if (!res.ok) {
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${res.status}</div>`;
loadMoreBtn.style.display = 'none';
totalSpan.textContent = '';
return;
}
const data = await res.json();
if (!data.success) {
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(data.error || 'unknown')}</div>`;
loadMoreBtn.style.display = 'none';
totalSpan.textContent = '';
return;
}
// Stale-response guard: a non-append load happened after this fetch,
// discard so we don't splice into the wrong DOM.
if (!append && myNonce !== filterNonce) return;
const logs = Array.isArray(data.logs) ? data.logs : [];
if (logs.length === 0 && !append) {
const reason = (data.filters && (data.filters.level || data.filters.context || data.filters.search || data.filters.since || data.filters.until))
? 'No error log entries match your filters.'
: '✅ No errors logged! Everything is working smoothly.';
container.innerHTML = `<div class="panel-empty"><span class="empty-icon">📋</span>${escapeHtml(reason)}</div>`;
loadMoreBtn.style.display = 'none';
totalSpan.textContent = data.total ? `${data.total} total` : '';
return;
}
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);">';
html += '<th style="padding: 6px; text-align: left; width: 160px;">When</th>';
html += '<th style="padding: 6px; text-align: left; width: 80px;">Level</th>';
html += '<th style="padding: 6px; text-align: left; width: 140px;">Context</th>';
html += '<th style="padding: 6px; text-align: left;">Message</th>';
html += '<th style="padding: 6px; text-align: left; width: 110px;">IP</th>';
html += '</tr>';
}
for (const log of logs) {
const level = (log.level || '?').toUpperCase();
const levelColor = level === 'ERR' ? 'var(--bad-fg)' : (level === 'WARN' ? 'var(--warn-fg, #f0c674)' : 'var(--muted)');
const ts = log.timestamp ? new Date(log.timestamp).toLocaleString() : '—';
const ctx = log.context || '—';
const msg = (log.error || '').split('\n')[0];
const ip = (log.request && log.request.ip) || '';
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">`;
html += `<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(log.timestamp || '')}">${escapeHtml(ts)}</td>`;
html += `<td style="padding: 6px;"><span style="color: ${levelColor}; font-weight: 600;">${escapeHtml(level)}</span></td>`;
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(ctx)}</td>`;
html += `<td style="padding: 6px;">${escapeHtml(msg)}</td>`;
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(ip)}</td>`;
html += '</tr>';
if (log.detail) {
html += `<tr class="error-log-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; max-height: 320px; overflow: auto;">${escapeHtml(log.detail)}</pre></td></tr>`;
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, 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="${levelClass(log.level)}">
<span class="log-timestamp">${escapeHtml(date)}</span>
<span class="log-level">${escapeHtml(lvl)}</span>
<div class="log-message">
${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('');
}
}
if (!append) {
html += '</table>';
container.innerHTML = html;
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 {
const table = container.querySelector('table');
if (table) table.insertAdjacentHTML('beforeend', html);
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
}
currentOffset += logs.length;
loadMoreBtn.style.display = data.hasMore ? '' : 'none';
totalSpan.textContent = `${data.total} total${data.hasMore ? ' (showing ' + currentOffset + ')' : ''}`;
// Toggle detail rows on click — same pattern as audit-log.js
container.querySelectorAll('.error-log-row').forEach((row) => {
if (row.dataset.wired) return;
row.dataset.wired = 'true';
row.addEventListener('click', () => {
const detail = row.nextElementSibling;
if (detail && detail.classList.contains('error-log-detail')) {
detail.style.display = detail.style.display === 'none' ? '' : 'none';
}
});
});
} catch (e) {
if (e && e.name === 'AbortError') return;
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
totalSpan.textContent = '';
} catch (error) {
content.innerHTML = `<div style="padding: 20px; color: var(--bad-fg);">❌ Error loading logs: ${escapeHtml(error.message)}</div>`;
}
}
async function clearLogs() {
if (!confirm('Clear the entire error log? This cannot be undone.')) return;
async function clearErrorLogs() {
if (!confirm('Clear all error logs?')) return;
try {
const res = await secureFetch('/api/v1/error-logs', {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ confirm: 'CLEAR' }),
});
const data = await res.json();
const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' });
const data = await response.json();
if (data.success) {
// After a clear, the contexts list will be empty — re-fetch so the
// dropdown reflects reality. Load the now-empty page in parallel.
await refreshContexts();
loadLogs(false);
showNotification('✅ Error logs cleared', 'success', 3000);
loadErrorLogs();
} else {
showNotification('❌ ' + (data.error || 'Clear failed'), 'error', 4000);
showNotification('❌ Failed to clear logs', 'error', 3000);
}
} catch (e) {
showNotification('❌ ' + e.message, 'error', 4000);
} catch (error) {
showNotification(`❌ Error: ${error.message}`, 'error', 3000);
}
}
// Debounce text-input changes so we don't refetch on every keystroke.
let searchDebounce;
function wireFilters() {
levelSel?.addEventListener('change', () => loadLogs(false));
contextSel?.addEventListener('change', () => loadLogs(false));
searchInput?.addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => loadLogs(false), 250);
});
let dateDebounce;
[sinceInput, untilInput].forEach((el) => {
el?.addEventListener('change', () => {
clearTimeout(dateDebounce);
dateDebounce = setTimeout(() => loadLogs(false), 250);
});
});
refreshBtn?.addEventListener('click', () => loadLogs(false));
loadMoreBtn?.addEventListener('click', () => loadLogs(true));
clearBtn?.addEventListener('click', clearLogs);
wireModal(modal, closeBtn);
}
viewBtn?.addEventListener('click', async () => {
modal?.classList.add('show');
await refreshContexts();
loadLogs(false);
viewBtn?.addEventListener('click', () => {
modal.classList.add('show');
loadErrorLogs();
});
wireFilters();
refreshBtn?.addEventListener('click', loadErrorLogs);
clearBtn?.addEventListener('click', clearErrorLogs);
levelSelect?.addEventListener('change', loadErrorLogs);
tailSelect?.addEventListener('change', loadErrorLogs);
wireModal(modal, closeBtn);
})();
-282
View File
@@ -1,282 +0,0 @@
// ========== DC-055: HOST JOURNALD LOG VIEWER ==========
// Streams host service logs (caddy, dashcaddy-api, docker, ssh, …) via the
// journalctl bind-mount added in start.sh. Server-Sent Events for live
// tailing; bounded non-streaming read for historical views.
(function() {
'use strict';
// Allow-list mirrors the backend's ALLOWED_UNITS so the dropdown stays
// honest when the bind-mount isn't available. The server is still the
// source of truth — anything not in its allow-list returns 400.
const UNIT_PRESETS = [
{ unit: 'caddy', label: 'Caddy (reverse proxy)' },
{ unit: 'dashcaddy-api', label: 'DashCaddy API (host systemd unit, not this container)' },
{ unit: 'docker', label: 'Docker daemon' },
{ unit: 'ssh', label: 'SSH server' },
{ unit: 'systemd-journald', label: 'systemd-journald' },
{ unit: 'tailscaled', label: 'Tailscale' },
{ unit: 'networkd-dispatcher', label: 'Networkd dispatcher' },
];
injectModal('journald-modal', `
<div id="journald-modal" class="weather-modal" style="z-index: 1002;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
<h3 style="margin: 0;">🛰️ Host Logs (journald)</h3>
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">
Stream host service logs from <code>journalctl</code> (read-only mount). Docker container logs are still in the <em>Container Logs</em> modal.
</p>
</div>
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end;">
<select id="jd-unit-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 220px;"></select>
<input type="text" id="jd-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 160px;" />
<input type="number" id="jd-tail" min="1" max="5000" value="200" title="Lines to load (historical view)" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 90px;" />
<button id="jd-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">🔄 Load tail</button>
<button id="jd-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">▶ Stream</button>
<button id="jd-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">✕</button>
<button id="jd-close" class="close-btn" style="padding: 6px 10px;">✕</button>
</div>
</div>
<div id="jd-meta" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem; flex-wrap: wrap;">
<span><strong>Source:</strong> <span id="jd-source">journald</span></span>
<span><strong>Unit:</strong> <span id="jd-unit-display">-</span></span>
<span><strong>Stream:</strong> <span id="jd-stream-state">disconnected</span></span>
</div>
<div id="jd-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a unit and click <em>Load tail</em> or <em>Stream</em>.</div>
</div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
<span id="jd-line-count">0 lines</span>
<span>|</span>
<span id="jd-filter-count">0 shown</span>
<span>|</span>
<span id="jd-overflow" style="display: none; color: var(--warn-fg, #fbbf24);">⚠ stream overflow — re-load with narrower window</span>
</div>
<button id="jd-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>
`);
const modal = document.getElementById('journald-modal');
const unitSelect = document.getElementById('jd-unit-select');
const searchInput = document.getElementById('jd-search');
const tailInput = document.getElementById('jd-tail');
const refreshBtn = document.getElementById('jd-refresh');
const streamBtn = document.getElementById('jd-stream');
const clearSearch = document.getElementById('jd-clear-search');
const closeBtn = document.getElementById('jd-close');
const closeBtn2 = document.getElementById('jd-close-btn');
const content = document.getElementById('jd-content');
const lineCount = document.getElementById('jd-line-count');
const filterCount = document.getElementById('jd-filter-count');
const overflowHint = document.getElementById('jd-overflow');
const unitDisplay = document.getElementById('jd-unit-display');
const streamState = document.getElementById('jd-stream-state');
let available = false; // /var/log/journal mounted?
let lines = []; // current buffer (array of {timestamp, unit, text})
let streaming = false;
let eventSource = null;
let searchTimer = null;
function escapeHtml(s) {
// Local re-declaration so we don't depend on a global; same semantics
// as the helper used by container-logs.js and error-logs.js.
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
function setAvailable(isAvailable) {
available = isAvailable;
unitSelect.innerHTML = '';
UNIT_PRESETS.forEach(p => {
const opt = document.createElement('option');
opt.value = p.unit;
opt.textContent = p.label + ' (' + p.unit + ')';
unitSelect.appendChild(opt);
});
unitSelect.disabled = !isAvailable;
if (!isAvailable) {
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>';
refreshBtn.disabled = true;
streamBtn.disabled = true;
} else {
refreshBtn.disabled = false;
streamBtn.disabled = false;
}
}
async function probeAvailable() {
try {
const resp = await fetch('/api/v1/logs/journal/units');
if (!resp.ok) { setAvailable(false); return; }
const data = await resp.json();
setAvailable(!!data.available);
} catch (e) {
setAvailable(false);
}
}
function renderLines() {
const term = (searchInput.value || '').trim().toLowerCase();
const filtered = term ? lines.filter(l => (l.textContent || '').toLowerCase().includes(term)) : lines;
if (filtered.length === 0) {
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries' + (term ? ` matching &quot;${escapeHtml(term)}&quot;` : '') + '</div>';
} else {
const html = filtered.map(line => {
const ts = line.timestamp ? escapeHtml(line.timestamp) : '—';
const t = escapeHtml(line.textContent);
return `<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${ts}</span>${t}</div>`;
}).join('');
content.innerHTML = html;
// Auto-scroll only if user is already at the bottom (don't fight them).
const nearBottom = content.scrollHeight - content.scrollTop - content.clientHeight < 80;
if (nearBottom) content.scrollTop = content.scrollHeight;
}
lineCount.textContent = `${lines.length} entries`;
filterCount.textContent = term ? `${filtered.length} of ${lines.length} shown` : `${lines.length} shown`;
}
async function loadTail() {
if (!available) return;
stopStream();
const unit = unitSelect.value;
if (!unit) return;
const tail = Math.max(1, Math.min(5000, Number(tailInput.value) || 200));
const term = (searchInput.value || '').trim();
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading…</div>';
try {
const url = new URL('/api/v1/logs/journal', window.location.origin);
url.searchParams.set('unit', unit);
url.searchParams.set('tail', String(tail));
if (term) url.searchParams.set('search', term);
const resp = await fetch(url.toString());
const data = await resp.json();
if (!resp.ok || !data.success) {
content.innerHTML = '<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: ' + escapeHtml((data && data.error) || ('HTTP ' + resp.status)) + '</div>';
return;
}
unitDisplay.textContent = unit;
lines = (data.entries || []).map(e => ({
timestamp: e.timestamp,
unit: e.unit,
textContent: e.text || '',
}));
overflowHint.style.display = 'none';
renderLines();
} catch (e) {
content.innerHTML = '<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: ' + escapeHtml(e.message) + '</div>';
}
}
function startStream() {
if (!available) return;
stopStream();
const unit = unitSelect.value;
if (!unit) return;
const term = (searchInput.value || '').trim();
unitDisplay.textContent = unit;
streamBtn.textContent = '⏸ Stop';
streamBtn.classList.add('streaming');
streamState.textContent = 'streaming';
streamState.style.color = 'var(--ok-fg, #4ade80)';
lines = [];
renderLines();
overflowHint.style.display = 'none';
const url = new URL('/api/v1/logs/journal/stream', window.location.origin);
url.searchParams.set('unit', unit);
if (term) url.searchParams.set('search', term);
eventSource = new EventSource(url.toString());
eventSource.onmessage = (ev) => {
try {
const entry = JSON.parse(ev.data);
if (entry.error) {
// Overflow / validation / bind-mount errors
if (/stream (exceeded|line cap)/.test(entry.error)) {
overflowHint.style.display = '';
stopStream();
}
content.innerHTML += '<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">⚠ ' + escapeHtml(entry.error) + '</div>';
content.scrollTop = content.scrollHeight;
return;
}
lines.push({
timestamp: entry.timestamp,
unit: entry.unit || unit,
textContent: entry.text || '',
});
// Hard cap to keep memory bounded if operator streams forever.
if (lines.length > 5000) {
lines = lines.slice(lines.length - 5000);
overflowHint.style.display = '';
}
renderLines();
} catch (_) {
// Ignore malformed events; the server is authoritative.
}
};
eventSource.onerror = () => {
// EventSource auto-reconnects; mark transient if we were expecting
// more, otherwise we closed it deliberately.
if (!streaming) return;
};
streaming = true;
}
function stopStream() {
streaming = false;
if (eventSource) {
try { eventSource.close(); } catch (_) { /* ignore */ }
eventSource = null;
}
streamBtn.textContent = '▶ Stream';
streamBtn.classList.remove('streaming');
streamState.textContent = 'disconnected';
streamState.style.color = 'var(--muted)';
}
function close() {
stopStream();
modal.classList.remove('show');
}
// Wire events
refreshBtn.addEventListener('click', loadTail);
streamBtn.addEventListener('click', () => streaming ? stopStream() : startStream());
clearSearch.addEventListener('click', () => { searchInput.value = ''; renderLines(); });
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(renderLines, 200);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { searchInput.value = ''; renderLines(); }
});
closeBtn.addEventListener('click', close);
closeBtn2.addEventListener('click', close);
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('show')) close();
});
// Reload tail automatically when the unit dropdown changes (if we have
// data already — saves a click).
unitSelect.addEventListener('change', () => {
if (lines.length > 0) loadTail();
});
// Hook into the existing "Container Logs" modal button so operators get a
// separate entry point; mirror the openContainerLogsModal pattern.
function openJournaldModal() {
modal.classList.add('show');
probeAvailable();
}
window.openJournaldModal = openJournaldModal;
document.getElementById('view-journald-logs')?.addEventListener('click', openJournaldModal);
})();
+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');
})();
+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-a24ef15882';
const CACHE = 'dashcaddy-shell-78eab743c2';
const PRECACHE = [
'/',
'/index.html',