[grade=B urn:ump:wokpmecjfauc7jvnxyyyu4fq3zkkvwge455a5mxlmi3pdzkfdw3a] feat(DC-171): DNS provider plug-in parity phase 1 — BIND 9 AXFR listing + capability honesty

RFC2136/BIND9 adapter gains listRecords via AXFR zone transfer (dig with
the same TSIG identity as nsupdate; temp 0600 keyfile, always cleaned up;
transfer-denial errors carry the exact allow-transfer fix). TSIG fields
validated (name charset, HMAC algorithm whitelist, base64 secret) before
keyfile interpolation.

Capability honesty at every surface: GET /dns/records 501s name the active
provider + its supported capabilities + zone-transfer pointer; MCP
dashcaddy_list_dns attaches actionable guidance for AI clients; setup
wizard shows per-provider capability hints at choice time (parity-pinned
by tests against the adapters).

Also fixed a latent rfc2136 bug: this._log instance property shadowed the
prototype logging method — every nsupdate call threw since the DC-170-era
refactor. CHANGELOG stale 'protocol-impossible' DC-171 entry superseded.

Judge: grade B, zero blockers (urn:ump:wokpmecjfauc7jvnxyyyu4fq3zkkvwge455a5mxlmi3pdzkfdw3a).
Polish applied post-grade: stale changelog entry corrected, TSIG field
validation + 4 extra failure-path tests (exact dig argv, ENOENT install
hint, non-denial failure passthrough, hostile-field rejection),
dns-records 501 test strengthened to pin provider name + capabilities.
Suite: backend 174 suites / 3754 tests, frontend 68/68.
This commit is contained in:
Hermes committed 2026-10-29 03:21:48 -07:00
1 parent f0ce96f471
commit 1a736c89cc
10 files changed
+704 -238

No files matched your search

+20 -4
View File
@@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- **DC-171: DNS provider plug-in parity, phase 1 (BIND 9 upgrade + capability
honesty).** RFC 2136 adapters can now LIST records: `listRecords` performs an
AXFR zone transfer via `dig` with the same TSIG identity as nsupdate (temp
0600 keyfile, cleaned up after), parses transfer output into
`{zone, count, records}`, and — when the server denies the transfer — errors
with the exact server-side fix (`allow-transfer { key <name>; };`). The
capability matrix is pinned by a parity test suite (adapters vs wizard hints
vs route guidance). `GET /api/v1/dns/records` 501s now name the active
provider, list what it CAN do, and point at AXFR/manager tools; the MCP
`dashcaddy_list_dns` tool attaches the same actionable guidance for AI
clients; the setup wizard shows per-provider capability hints at choice time.
Also fixed a latent rfc2136 bug: an instance `_log` property shadowed the
logging method, so every nsupdate call threw after the DC-170-era refactor.
- **DC-170: full-software reliability and truth sweep (2026-10-27/29).** 46 findings - **DC-170: full-software reliability and truth sweep (2026-10-27/29).** 46 findings
from a three-agent audit, top items fixed in 7 graded batches: TOTP gate no longer from a three-agent audit, top items fixed in 7 graded batches: TOTP gate no longer
blocks API-key/JWT auth (machine credentials were dead on every TOTP install — blocks API-key/JWT auth (machine credentials were dead on every TOTP install —
@@ -54,10 +67,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
endpoint advertises what the running build actually does (route counts, endpoint advertises what the running build actually does (route counts,
templates, MCP tools measured at runtime), so the marketing site, docs, and templates, MCP tools measured at runtime), so the marketing site, docs, and
llms.txt can never disagree with the product. llms.txt can never disagree with the product.
- **DC-171 (ticketed, pending): DNS provider plug-in parity** — capability - **DC-171 (roadmap): further provider plug-in parity** — BIND 9 AXFR-based
reporting at provider-choice time (RFC2136 is push-only: create/resolve yes, listing shipped in phase 1 (above); remaining roadmap: PowerDNS /
listing is protocol-impossible); BIND 9 near-parity via the RFC2136 adapter deSEC / AdGuard Home adapters, richer per-provider setup validation.
plus AXFR-based listing; PowerDNS/deSEC/AdGuard Home adapters on the roadmap. ~~Capability reporting at provider-choice time~~ shipped in phase 1
(wizard hints + route guidance + MCP guidance); the original "listing is
protocol-impossible" note was superseded — RFC2136 listing works via
AXFR zone transfer with the `allow-transfer` prerequisite.
### Notes ### Notes
- Historical per-change review outcomes are recorded in commit messages and in - Historical per-change review outcomes are recorded in commit messages and in
@@ -0,0 +1,261 @@
/**
* DC-171 — DNS provider plug-in parity.
*
* Pins the capability matrix for every shipped adapter against the
* documented DC-171 matrix, verifies the RFC2136/BIND AXFR listRecords
* implementation (parse + TSIG keyfile + guidance errors), and asserts the
* setup wizard's provider-hints stay in sync with the adapters'
* getCapabilities() so the choice-time reporting can't silently drift.
*/
'use strict';
const fs = require('fs');
const path = require('path');
jest.mock('child_process', () => ({
execFile: jest.fn(),
}));
const { execFile } = require('child_process');
const { promisify } = require('util');
// The adapter promisifies execFile at require time; rebuild the promise form
// per-test from the mocked execFile.
let execFileImpl;
beforeEach(() => {
execFile.mockImplementation((cmd, args, opts, cb) => {
execFileImpl(cmd, args, opts)
.then((r) => cb(null, r))
.catch((e) => cb(e));
});
});
function loadProvider(file) {
const Cls = require(path.join(REPO, 'src', 'dns', 'dns-providers', file));
return new Cls({}, {
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
});
}
const REPO = path.join(__dirname, '..', '..');
// ── DC-171 capability matrix ────────────────────────────────────────────────
describe('DC-171 capability matrix (provider plug-in parity)', () => {
const MATRIX = {
// protocol-complete adapters
'technitium.js': {
must: ['create-record', 'delete-record', 'resolve', 'list-records', 'zones'],
forbidden: [],
},
'cloudflare.js': {
must: ['create-record', 'delete-record', 'resolve', 'list-records'],
forbidden: [],
},
// RFC 2136 is push-only; DC-171 added AXFR-based listing (BIND 9 parity).
'rfc2136.js': {
must: ['create-record', 'delete-record', 'resolve', 'credentials', 'list-records'],
forbidden: ['logs', 'restart', 'update-check', 'zones'],
},
// instructions-only by design
'manual.js': {
must: [],
forbidden: ['list-records', 'logs', 'restart', 'update-check'],
},
};
for (const [file, { must, forbidden }] of Object.entries(MATRIX)) {
test(`${file} declares exactly the DC-171 matrix`, () => {
const p = loadProvider(file);
const caps = p.getCapabilities();
for (const cap of must) {
expect(caps).toContain(cap);
expect(p.supportsCapability(cap)).toBe(true);
}
for (const cap of forbidden) {
expect(caps).not.toContain(cap);
expect(p.supportsCapability(cap)).toBe(false);
}
});
}
test('every registered adapter file is covered by the matrix', () => {
const dir = path.join(REPO, 'src', 'dns', 'dns-providers');
const adapters = fs.readdirSync(dir)
.filter((f) => f.endsWith('.js') && f !== 'base.js' && f !== 'registry.js')
.sort();
expect(adapters).toEqual(Object.keys(MATRIX).sort());
});
});
// ── RFC2136/BIND AXFR listRecords ───────────────────────────────────────────
describe('RFC2136 listRecords via AXFR (BIND 9 parity, DC-171)', () => {
function makeProvider(config) {
const p = loadProvider('rfc2136.js');
Object.assign(p, {
server: '192.0.2.10',
port: 53,
zone: 'home.example.com',
tsigKeyName: 'dashcaddy-key',
tsigSecret: 'base64secret==',
tsigAlgorithm: 'hmac-sha256',
...config,
});
return p;
}
// Emulates `dig AXFR +onesoa` stdout: one SOA bookend, no ;; comments.
const AXFR_OUTPUT = [
'home.example.com. 3600 IN SOA ns1.home.example.com. admin.home.example.com. 42 3600 600 86400 300',
'home.example.com. 300 IN NS ns1.home.example.com.',
'host.home.example.com. 300 IN A 10.0.0.5',
'nas.home.example.com. 300 IN A 10.0.0.6',
'plex.home.example.com. 300 IN CNAME home.example.com.',
].join('\n');
function mockDig(stdout, stderr = '') {
execFileImpl = async () => {
const err = new Error('dig exited');
err.stderr = stderr;
err.stdout = stdout;
if (stderr) throw err;
return { stdout, stderr: '' };
};
}
test('parses AXFR output into records and skips the second SOA bookend', async () => {
const fsMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig(AXFR_OUTPUT);
const p = makeProvider();
const result = await p.listRecords({});
expect(result.zone).toBe('home.example.com');
// +onesoa: exactly one SOA row survives
expect(result.records.filter((r) => r.type === 'SOA')).toHaveLength(1);
expect(result.count).toBe(result.records.length);
const a = result.records.find((r) => r.name === 'host.home.example.com.');
expect(a).toMatchObject({ type: 'A', value: '10.0.0.5', ttl: 300 });
const cname = result.records.find((r) => r.type === 'CNAME');
expect(cname.value).toBe('home.example.com.');
// TSIG keyfile was written 0600 and cleaned up
expect(fsMock.mock.calls[0][2]).toMatchObject({ mode: 0o600 });
expect(unlinkMock).toHaveBeenCalled();
fsMock.mockRestore();
unlinkMock.mockRestore();
});
test('transfer denial carries the allow-transfer guidance with the key name', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig('', ';; Transfer failed: REFUSED');
const p = makeProvider();
await expect(p.listRecords({})).rejects.toThrow(/allow-transfer \{ key dashcaddy-key; \};/);
});
test('no-TSIG denial guides toward allow-transfer generally', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig('', ';; Transfer failed: REFUSED');
const p = makeProvider({ tsigKeyName: null, tsigSecret: null });
await expect(p.listRecords({})).rejects.toThrow(/Configure 'allow-transfer'/);
});
test('missing zone config throws before invoking dig', async () => {
const p = makeProvider({ zone: null });
await expect(p.listRecords({})).rejects.toThrow(/zone/i);
expect(execFile).not.toHaveBeenCalled();
});
test('builds the exact dig argument vector with the TSIG keyfile', async () => {
const writeFileMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig(AXFR_OUTPUT);
const p = makeProvider();
await p.listRecords({ zone: 'other.example.com' });
const [, args] = execFile.mock.calls[0];
expect(args[0]).toBe('@192.0.2.10');
expect(args.slice(1, 3)).toEqual(['-p', '53']);
expect(args[3]).toBe('AXFR');
expect(args[4]).toBe('other.example.com.');
expect(args.slice(5)).toContain('+onesoa');
const kIdx = args.indexOf('-k');
expect(kIdx).toBeGreaterThan(-1);
// keyfile content is valid BIND key syntax with the configured identity
const keyContent = writeFileMock.mock.calls[0][1];
expect(keyContent).toContain('key "dashcaddy-key"');
expect(keyContent).toContain('algorithm hmac-sha256;');
expect(keyContent).toContain('secret "base64secret=="');
});
test('missing dig binary maps ENOENT to an install hint', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
execFileImpl = async () => {
const err = new Error("spawn dig ENOENT");
err.code = 'ENOENT';
throw err;
};
const p = makeProvider();
await expect(p.listRecords({})).rejects.toThrow(/bind9utils|bind-utils/);
expect(unlinkMock).toHaveBeenCalled(); // keyfile still cleaned up
});
test('non-denial dig failure is rethrown with context, keyfile cleaned up', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
execFileImpl = async () => {
throw new Error('communication timed out');
};
const p = makeProvider();
await expect(p.listRecords({})).rejects.toThrow(/AXFR failed: communication timed out/);
expect(unlinkMock).toHaveBeenCalled();
});
test('rejects hostile TSIG fields before writing any keyfile', async () => {
const writeFileMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const badName = makeProvider({ tsigKeyName: 'evil"; }; ' });
await expect(badName.listRecords({})).rejects.toThrow(/Invalid TSIG key name/);
const badAlgo = makeProvider({ tsigAlgorithm: 'hmac-rainbow' });
await expect(badAlgo.listRecords({})).rejects.toThrow(/Unsupported TSIG algorithm/);
const badSecret = makeProvider({ tsigSecret: 'abc "def' });
await expect(badSecret.listRecords({})).rejects.toThrow(/Invalid TSIG secret/);
expect(writeFileMock).not.toHaveBeenCalled(); // nothing written for bad inputs
});
});
// ── Setup wizard choice-time hints stay in sync with the adapters ───────────
describe('DC-171 setup wizard provider hints sync', () => {
// status/ lives one level above dashcaddy-api in the monorepo
const MONOREPO = path.join(__dirname, '..', '..', '..');
test('every wizard <option> value has a hint entry and vice versa', () => {
const html = fs.readFileSync(path.join(MONOREPO, 'status', 'index.html'), 'utf8');
const selectMatch = html.match(/<select id="setup-dns-provider"[^>]*>([\s\S]*?)<\/select>/);
expect(selectMatch).toBeTruthy();
const options = [...selectMatch[1].matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
expect(options.length).toBeGreaterThan(0);
const wizard = fs.readFileSync(path.join(MONOREPO, 'status', 'js', 'setup-wizard.js'), 'utf8');
const mapMatch = wizard.match(/const DNS_PROVIDER_HINTS = \{([\s\S]*?)\n \};/);
// The closer is anchored to a real closing line: the rfc2136 hint text
// legitimately contains a bare '};' (allow-transfer syntax) mid-string.
expect(mapMatch).toBeTruthy();
const hintKeys = [...mapMatch[1].matchAll(/^\s{4}(\w+):/gm)].map((m) => m[1]);
expect(hintKeys.sort()).toEqual(options.sort());
});
});
@@ -59,11 +59,22 @@ describe('GET /dns/records (DC-170 route + DC-171 capability behavior)', () => {
}); });
test('provider WITHOUT list-records capability answers 501 with guidance', async () => { test('provider WITHOUT list-records capability answers 501 with guidance', async () => {
// RFC2136-style: push-only protocol, cannot enumerate records // RFC2136-style: push-only protocol, cannot enumerate records.
const dns = { getActiveProvider: () => ({ supportsCapability: () => false }) }; // DC-171: the guidance must name the provider and its supported capabilities.
const dns = {
getActiveProvider: () => ({
supportsCapability: () => false,
getCapabilities: () => ['create-record', 'delete-record', 'resolve'],
}),
getProviderId: () => 'rfc2136',
};
const res = await request(buildApp(dns)).get('/dns/records'); const res = await request(buildApp(dns)).get('/dns/records');
expect(res.status).toBe(501); expect(res.status).toBe(501);
expect(String(res.body.error || res.body.message)).toMatch(/not supported/i); const msg = String(res.body.error || res.body.message);
expect(msg).toMatch(/not supported/i);
expect(msg).toMatch(/rfc2136/);
expect(msg).toMatch(/create-record, delete-record, resolve/);
expect(msg).toMatch(/zone transfers/i);
}); });
test('missing/unresolvable active provider answers 501, does not crash', async () => { test('missing/unresolvable active provider answers 501, does not crash', async () => {
+10 -1
View File
@@ -73,7 +73,16 @@ module.exports = function({
const zone = req.query.zone || (siteConfig.tld ? String(siteConfig.tld).replace(/^\./, '') : ''); const zone = req.query.zone || (siteConfig.tld ? String(siteConfig.tld).replace(/^\./, '') : '');
const provider = dns.getActiveProvider ? dns.getActiveProvider() : null; const provider = dns.getActiveProvider ? dns.getActiveProvider() : null;
if (!provider || (provider.supportsCapability && !provider.supportsCapability('list-records'))) { if (!provider || (provider.supportsCapability && !provider.supportsCapability('list-records'))) {
return errorResponse(res, 'Record listing not supported by current DNS provider', 501); // DC-171: actionable guidance — name the provider and what it CAN do
const providerId = dns.getProviderId ? dns.getProviderId() : 'unknown';
const caps = provider && provider.getCapabilities ? provider.getCapabilities() : [];
return errorResponse(res,
`Record listing is not supported by the active DNS provider (${providerId}). `
+ `Supported capabilities: ${caps.join(', ') || 'none'}. `
+ `Push-only protocols (RFC 2136/BIND dynamic updates) can create, delete and `
+ `resolve records but cannot enumerate them server-side — list them with the `
+ `server's own tools (e.g. dig, the provider's web UI) or enable zone transfers.`,
501);
} }
const result = await provider.listRecords({ zone }); const result = await provider.listRecords({ zone });
const records = Array.isArray(result) ? result : (result && result.records) || []; const records = Array.isArray(result) ? result : (result && result.records) || [];
+131 -6
View File
@@ -4,8 +4,15 @@
* Manages DNS records via RFC 2136 dynamic updates using the nsupdate CLI tool. * Manages DNS records via RFC 2136 dynamic updates using the nsupdate CLI tool.
* Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server. * Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server.
* *
* Capabilities: create-record, delete-record, resolve, credentials * Capabilities: create-record, delete-record, resolve, list-records (AXFR), credentials
* Not supported: logs, restart, update-check, list-records, zones * Not supported: logs, restart, update-check, zones
*
* DC-171 (BIND 9 plug-in parity): `list-records` is implemented via AXFR zone
* transfer (`dig AXFR`) using the same TSIG identity as nsupdate. Server-side
* prerequisite: the zone must permit transfers for this key — BIND:
* allow-transfer { key <tsigKeyName>; };
* RFC 2136 itself has no query operation, so listing is transfer-based; when
* the server denies the transfer, listRecords throws with that guidance.
*/ */
const { execFile } = require('child_process'); const { execFile } = require('child_process');
@@ -20,7 +27,7 @@ const execFileAsync = promisify(execFile);
const BaseDNSProvider = require('./base'); const BaseDNSProvider = require('./base');
const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'credentials']; const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'list-records', 'credentials'];
const DEFAULT_PORT = 53; const DEFAULT_PORT = 53;
const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256'; const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256';
@@ -34,7 +41,7 @@ class RFC2136Provider extends BaseDNSProvider {
super(config, ctx); super(config, ctx);
this.providerId = 'rfc2136'; this.providerId = 'rfc2136';
this.displayName = 'RFC 2136 (Dynamic DNS)'; this.displayName = 'RFC 2136 / BIND 9 (Dynamic DNS)';
// Core config // Core config
this.server = config.server || null; this.server = config.server || null;
@@ -56,8 +63,10 @@ class RFC2136Provider extends BaseDNSProvider {
} }
} }
// Logger shorthand // NOTE: no logger shorthand here — the _log() prototype method (below)
this._log = ctx && ctx.log ? ctx.ctx : null; // reads this.ctx.log. A previous version assigned an instance property
// (this._log = ctx.ctx) that shadowed the method with undefined and
// threw on every nsupdate call; fixed in DC-171.
} }
// ── Logging helper ──────────────────────────────────────────────────────── // ── Logging helper ────────────────────────────────────────────────────────
@@ -274,6 +283,122 @@ class RFC2136Provider extends BaseDNSProvider {
}; };
} }
// ── List Records (AXFR zone transfer, DC-171) ────────────────────────────
/**
* Write a temporary dig TSIG keyfile from the configured key name/secret.
* nsupdate accepts the key inline in its script; dig -k requires a file:
* key "name" { algorithm <algo>; secret "<secret>"; };
* Returns the temp file path, or null when no TSIG identity is configured.
* Fields are validated before interpolation — a hostile/corrupt value must
* fail loudly here rather than produce a broken or misleading keyfile.
*/
async _writeDigKeyFile() {
if (!this.tsigKeyName || !this.tsigSecret) return null;
// Key name: an unquoted DNS hostname token (letters, digits, dots, dash,
// underscore for dnstool-generated names). Reject quotes/whitespace/;{}.
if (!/^[A-Za-z0-9._-]+$/.test(this.tsigKeyName)) {
throw new Error(`Invalid TSIG key name '${this.tsigKeyName}': expected a plain DNS name (A-Z a-z 0-9 . _ -).`);
}
// Algorithm: whitelist the RFC-standard HMAC algorithms dig supports.
const algo = String(this.tsigAlgorithm || DEFAULT_TSIG_ALGORITHM).toLowerCase();
if (!['hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512'].includes(algo)) {
throw new Error(`Unsupported TSIG algorithm '${this.tsigAlgorithm}': use one of hmac-md5/sha1/sha224/sha256/sha384/sha512.`);
}
// Secret: base64 alphabet only (dig rejects anything else anyway, but a
// stray quote here would corrupt the keyfile silently).
if (!/^[A-Za-z0-9+/=_-]+$/.test(this.tsigSecret)) {
throw new Error('Invalid TSIG secret: expected a base64 string (no quotes, whitespace or punctuation).');
}
const keyFile = path.join(os.tmpdir(), `digkey-${crypto.randomBytes(4).toString('hex')}.key`);
const content = `key "${this.tsigKeyName}" {\n algorithm ${algo};\n secret "${this.tsigSecret}";\n};\n`;
await fs.promises.writeFile(keyFile, content, { mode: 0o600 });
return keyFile;
}
/**
* List all records in a zone via AXFR zone transfer (DC-171 — BIND 9
* plug-in parity). Uses the same TSIG identity as nsupdate. Server-side
* prerequisite: the zone must permit transfers for this identity — BIND:
* allow-transfer { key <tsigKeyName>; };
* RFC 2136 has no query operation, so listing is transfer-based; when the
* server denies it, the thrown error carries that exact guidance.
*
* @param {Object} [params]
* @param {string} [params.zone] - Zone to transfer (defaults to config zone)
* @returns {Object} { zone, count, records: [{name, ttl, type, value}] }
*/
async listRecords({ zone } = {}) {
const effectiveZone = zone || this.zone;
if (!effectiveZone) throw new Error('Missing required config: zone');
const fqdnZone = this._ensureFqdn(effectiveZone);
const args = [
`@${this.server}`,
'-p', String(this.port),
'AXFR', fqdnZone,
'+nocomments', '+onesoa', '+nostats',
'+time=10', '+tries=1',
];
const keyFile = await this._writeDigKeyFile();
if (keyFile) args.push('-k', keyFile);
let stdout = '';
try {
const result = await execFileAsync('dig', args, {
timeout: 20000,
maxBuffer: 8 * 1024 * 1024,
});
stdout = result.stdout || '';
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('dig command not found. Install bind9utils (Debian/Ubuntu) or bind-utils (RHEL/CentOS).');
}
const combined = `${err.stderr || ''} ${err.stdout || ''}`;
if (/transfer failed|refused|denied/i.test(combined)) {
const hint = this.tsigKeyName
? `Zone transfer denied for '${effectiveZone}'. Add 'allow-transfer { key ${this.tsigKeyName}; };' to the zone in the server config and reload.`
: `Zone transfer denied for '${effectiveZone}'. Configure 'allow-transfer' for this identity on the server.`;
throw new Error(`AXFR failed: ${hint}`);
}
throw new Error(`AXFR failed: ${err.message}`);
} finally {
if (keyFile) {
try { await fs.promises.unlink(keyFile); } catch (_) { /* ignore */ }
}
}
// dig AXFR record lines: "name [ttl] [class] type rdata..."
// (+nocomments strips the ;; header/footer; +onesoa leaves one SOA bookend)
const records = [];
for (const raw of stdout.split('\n')) {
const line = raw.replace(/\s+;.*$/, '').trim(); // drop trailing comments
if (!line || line.startsWith(';')) continue;
const m = line.match(/^(\S+)\s+(\d+)\s+(?:IN\s+)?(\S+)\s+(.+)$/);
if (!m) continue;
let name = m[1];
// Relative owner names in transfer output belong to the zone origin
if (name === '@') name = fqdnZone;
records.push({
name,
zone: effectiveZone,
ttl: parseInt(m[2], 10),
type: m[3].toUpperCase(),
value: m[4].trim(),
});
}
this._log('info', 'AXFR zone transfer completed', {
zone: effectiveZone, count: records.length,
});
return { zone: effectiveZone, count: records.length, records };
}
// ── Resolve Records ─────────────────────────────────────────────────────── // ── Resolve Records ───────────────────────────────────────────────────────
/** /**
+15 -1
View File
@@ -559,7 +559,21 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
// DC-170: GET /dns never existed — records live at GET /dns/records // DC-170: GET /dns never existed — records live at GET /dns/records
let path = '/dns/records'; let path = '/dns/records';
if (args.zone) path += `?zone=${encodeURIComponent(args.zone)}`; if (args.zone) path += `?zone=${encodeURIComponent(args.zone)}`;
return apiCall('GET', path); const result = await apiCall('GET', path);
// DC-171: push-only providers (RFC2136/BIND) cannot enumerate records —
// give the AI client actionable guidance instead of a bare 501 envelope.
if (result.error && result.status === 501) {
return {
...result,
guidance: 'The active DNS provider cannot list records server-side. '
+ 'Use dashcaddy_dns_add_record and dashcaddy_dns_remove_record to manage '
+ 'records (dashcaddy_dns_remove_record also confirms existence), and verify '
+ 'propagation by resolving the record. Full listing requires a provider with '
+ 'the list-records capability (Technitium, Cloudflare) or AXFR zone transfers '
+ 'enabled on an RFC2136/BIND server.',
};
}
return result;
} }
case 'dashcaddy_generate_caddyfile': { case 'dashcaddy_generate_caddyfile': {
+221 -221
View File
@@ -90,44 +90,44 @@
<button id="logo-save" class="btn-accent">Save</button> <button id="logo-save" class="btn-accent">Save</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("logo-modal"),x=document.getElementById("logo-preview-dark"),j=document.getElementById("logo-preview-light"),g=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),R=document.getElementById("logo-upload-dark"),h=document.getElementById("logo-upload-light"),C=document.getElementById("logo-upload-single"),E=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),z=document.getElementById("dashboard-title"),S=DC.NAME;let T=null,D=null,y=null,B="left",$=S;N?.addEventListener("change",()=>{N.checked?(P.style.display="none",M.style.display="",T=null,D=null):(P.style.display="flex",M.style.display="none",y=null)});function U(e,s){if(!e||!e.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const r=new FileReader;r.onload=o=>s(o.target.result),r.readAsDataURL(e)}R?.addEventListener("change",e=>{U(e.target.files[0],s=>{T=s,x.src=s,g.textContent="New dark logo ready to save"})}),h?.addEventListener("change",e=>{U(e.target.files[0],s=>{D=s,j.src=s,g.textContent="New light logo ready to save"})}),C?.addEventListener("change",e=>{U(e.target.files[0],s=>{y=s,x.src=s,j.src=s,g.textContent="New logo ready to save (both themes)"})});function m(e){k.setAttribute("data-logo-pos",e),document.querySelectorAll(".logo-pos-btn").forEach(s=>{s.style.background=s.dataset.pos===e?"var(--accent)":"var(--card-bg)",s.style.color=s.dataset.pos===e?"white":"var(--fg)"})}function v(e){$=e||S,document.title=$;const s=document.querySelector(".dashboard-title");s&&(s.textContent=$)}async function I(){try{const e=await fetch("/api/v1/logo");if(e.ok){const s=await e.json();s.customLogoDark&&(E.src=s.customLogoDark,x.src=s.customLogoDark),s.customLogoLight&&(H.src=s.customLogoLight,j.src=s.customLogoLight),!s.customLogoDark&&!s.customLogoLight&&s.customLogo&&(E.src=s.customLogo,H.src=s.customLogo,x.src=s.customLogo,j.src=s.customLogo),s.isDefault||(g.textContent="Using custom logo"),s.position&&(B=s.position,m(s.position)),s.dashboardTitle&&v(s.dashboardTitle)}}catch(e){console.warn("Could not load custom logo:",e.message)}}document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.addEventListener("click",()=>{B=e.dataset.pos,m(B)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,D=null,y=null,R&&(R.value=""),h&&(h.value=""),C&&(C.value=""),N&&(N.checked=!1),P.style.display="flex",M.style.display="none",x.src=E.src,j.src=H.src;const e=E.src.includes("custom-logo")||H.src.includes("custom-logo");g.textContent=e?"Using custom logo":"Using default logos",m(B),z.value=$,w.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const e=z.value.trim()||S,s={position:B,dashboardTitle:e};N?.checked&&y?(s.dataDark=y,s.dataLight=y):(T&&(s.dataDark=T),D&&(s.dataLight=D));const r=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(r.ok){const o=await r.json(),a="?t="+Date.now();o.pathDark&&(E.src=o.pathDark+a,x.src=o.pathDark+a),o.pathLight&&(H.src=o.pathLight+a,j.src=o.pathLight+a),m(B),v(e),w.classList.remove("show")}else{const o=await r.json();showNotification("Failed to save: "+o.error,"error")}}catch(e){showNotification("Error saving: "+e.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? </div>`);const b=document.getElementById("logo-modal"),h=document.getElementById("logo-preview-dark"),j=document.getElementById("logo-preview-light"),g=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),R=document.getElementById("logo-upload-dark"),f=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),z=document.getElementById("dashboard-title"),k=DC.NAME;let C=null,D=null,y=null,T="left",$=k;N?.addEventListener("change",()=>{N.checked?(P.style.display="none",M.style.display="",C=null,D=null):(P.style.display="flex",M.style.display="none",y=null)});function F(e,i){if(!e||!e.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const l=new FileReader;l.onload=s=>i(s.target.result),l.readAsDataURL(e)}R?.addEventListener("change",e=>{F(e.target.files[0],i=>{C=i,h.src=i,g.textContent="New dark logo ready to save"})}),f?.addEventListener("change",e=>{F(e.target.files[0],i=>{D=i,j.src=i,g.textContent="New light logo ready to save"})}),L?.addEventListener("change",e=>{F(e.target.files[0],i=>{y=i,h.src=i,j.src=i,g.textContent="New logo ready to save (both themes)"})});function S(e){x.setAttribute("data-logo-pos",e),document.querySelectorAll(".logo-pos-btn").forEach(i=>{i.style.background=i.dataset.pos===e?"var(--accent)":"var(--card-bg)",i.style.color=i.dataset.pos===e?"white":"var(--fg)"})}function I(e){$=e||k,document.title=$;const i=document.querySelector(".dashboard-title");i&&(i.textContent=$)}async function B(){try{const e=await fetch("/api/v1/logo");if(e.ok){const i=await e.json();i.customLogoDark&&(w.src=i.customLogoDark,h.src=i.customLogoDark),i.customLogoLight&&(H.src=i.customLogoLight,j.src=i.customLogoLight),!i.customLogoDark&&!i.customLogoLight&&i.customLogo&&(w.src=i.customLogo,H.src=i.customLogo,h.src=i.customLogo,j.src=i.customLogo),i.isDefault||(g.textContent="Using custom logo"),i.position&&(T=i.position,S(i.position)),i.dashboardTitle&&I(i.dashboardTitle)}}catch(e){console.warn("Could not load custom logo:",e.message)}}document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.addEventListener("click",()=>{T=e.dataset.pos,S(T)})}),document.getElementById("brand")?.addEventListener("click",()=>{C=null,D=null,y=null,R&&(R.value=""),f&&(f.value=""),L&&(L.value=""),N&&(N.checked=!1),P.style.display="flex",M.style.display="none",h.src=w.src,j.src=H.src;const e=w.src.includes("custom-logo")||H.src.includes("custom-logo");g.textContent=e?"Using custom logo":"Using default logos",S(T),z.value=$,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const e=z.value.trim()||k,i={position:T,dashboardTitle:e};N?.checked&&y?(i.dataDark=y,i.dataLight=y):(C&&(i.dataDark=C),D&&(i.dataLight=D));const l=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(l.ok){const s=await l.json(),a="?t="+Date.now();s.pathDark&&(w.src=s.pathDark+a,h.src=s.pathDark+a),s.pathLight&&(H.src=s.pathLight+a,j.src=s.pathLight+a),S(T),I(e),b.classList.remove("show")}else{const s=await l.json();showNotification("Failed to save: "+s.error,"error")}}catch(e){showNotification("Error saving: "+e.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&&(E.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",x.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",g.textContent="Using default logos",T=null,D=null,y=null,z.value=S,v(S),B="left",m("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const r=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),a=document.getElementById("favicon-status");r&&(r.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.textContent="Using DashCaddy favicon"),d=null}}catch(e){showNotification("Error resetting branding: "+e.message,"error")}}),wireModal(w,document.getElementById("logo-cancel"));const b=document.getElementById("favicon-preview"),L=document.getElementById("favicon-status"),l=document.getElementById("favicon-upload");if(b){const e=()=>{b.src="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect fill=%22%23667%22 width=%2232%22 height=%2232%22 rx=%224%22/></svg>"};b.addEventListener("error",e),b.complete&&b.naturalWidth===0&&e()}const 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 f(){try{const e=await fetch("/api/v1/favicon");if(e.ok){const s=await e.json();s.customFavicon&&(c.href=s.customFavicon+"?t="+Date.now(),b.src=s.customFavicon+"?t="+Date.now(),L.textContent="Using custom favicon")}}catch(e){console.warn("Could not load custom favicon:",e.message)}}l?.addEventListener("change",e=>{const s=e.target.files[0];if(!s)return;if(!s.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),l.value="";return}const r=new FileReader;r.onload=o=>{d=o.target.result,b.src=d,L.textContent="New favicon ready to save"},r.readAsDataURL(s)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const e=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(e.ok){const s=await e.json();c.href=s.path+"?t="+Date.now(),b.src=s.path+"?t="+Date.now(),L.textContent="Using custom favicon",d=null}else{const s=await e.json();showNotification("Failed to save favicon: "+s.error,"error")}}catch(e){showNotification("Error saving favicon: "+e.message,"error")}}),f(),I();const p=document.getElementById("settings-timezone");p&&(new MutationObserver(()=>{w.classList.contains("show")&&p.options.length===0&&(async()=>{let s;try{const r=await fetch("/api/v1/config");r.ok&&(s=(await r.json()).timezone)}catch{}window.populateTimezoneSelect(p,s)})()}).observe(w,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const s=p.value;if(s)try{const r=await fetch("/api/v1/config");if(!r.ok)return;const o=await r.json();o.timezone=s,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(r){console.warn("Failed to save timezone:",r.message)}}))})(),window.populateTimezoneSelect=function(w,x){const j=Intl.supportedValuesOf("timeZone"),g=x||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";w.innerHTML="";for(const N of j){const P=document.createElement("option");P.value=N,P.textContent=N.replace(/_/g," "),N===g&&(P.selected=!0),w.appendChild(P)}},(function(){let w="homelab",x=null;async function j(){try{const v=await fetch("/api/v1/config");if(v.ok&&(x=await v.json(),x&&x.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)}j();const g=document.getElementById("setup-timezone");g&&window.populateTimezoneSelect(g);function N(m){document.querySelectorAll(".setup-step").forEach(I=>{I.style.display="none"});const v=document.getElementById(m);v&&(v.style.display="block")}function P(){const m=document.getElementById("setup-summary-content");if(!m)return;let v='<div style="display: grid; gap: 20px;">';if(w==="homelab"){const b=document.getElementById("setup-tld")?.value?.trim()||".home",L=document.getElementById("setup-ca-name")?.value?.trim()||"",l=document.getElementById("setup-dns-ip")?.value?.trim()||"",c=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&&(w.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",h.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",g.textContent="Using default logos",C=null,D=null,y=null,z.value=k,I(k),T="left",S("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const l=document.querySelector('link[rel="icon"]'),s=document.getElementById("favicon-preview"),a=document.getElementById("favicon-status");l&&(l.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),s&&(s.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.textContent="Using DashCaddy favicon"),d=null}}catch(e){showNotification("Error resetting branding: "+e.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const E=document.getElementById("favicon-preview"),u=document.getElementById("favicon-status"),o=document.getElementById("favicon-upload");if(E){const e=()=>{E.src="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect fill=%22%23667%22 width=%2232%22 height=%2232%22 rx=%224%22/></svg>"};E.addEventListener("error",e),E.complete&&E.naturalWidth===0&&e()}const 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 v(){try{const e=await fetch("/api/v1/favicon");if(e.ok){const i=await e.json();i.customFavicon&&(c.href=i.customFavicon+"?t="+Date.now(),E.src=i.customFavicon+"?t="+Date.now(),u.textContent="Using custom favicon")}}catch(e){console.warn("Could not load custom favicon:",e.message)}}o?.addEventListener("change",e=>{const i=e.target.files[0];if(!i)return;if(!i.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),o.value="";return}const l=new FileReader;l.onload=s=>{d=s.target.result,E.src=d,u.textContent="New favicon ready to save"},l.readAsDataURL(i)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const e=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(e.ok){const i=await e.json();c.href=i.path+"?t="+Date.now(),E.src=i.path+"?t="+Date.now(),u.textContent="Using custom favicon",d=null}else{const i=await e.json();showNotification("Failed to save favicon: "+i.error,"error")}}catch(e){showNotification("Error saving favicon: "+e.message,"error")}}),v(),B();const p=document.getElementById("settings-timezone");p&&(new MutationObserver(()=>{b.classList.contains("show")&&p.options.length===0&&(async()=>{let i;try{const l=await fetch("/api/v1/config");l.ok&&(i=(await l.json()).timezone)}catch{}window.populateTimezoneSelect(p,i)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const i=p.value;if(i)try{const l=await fetch("/api/v1/config");if(!l.ok)return;const s=await l.json();s.timezone=i,s.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}catch(l){console.warn("Failed to save timezone:",l.message)}}))})(),window.populateTimezoneSelect=function(b,h){const j=Intl.supportedValuesOf("timeZone"),g=h||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const N of j){const P=document.createElement("option");P.value=N,P.textContent=N.replace(/_/g," "),N===g&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",h=null;async function j(){try{const o=await fetch("/api/v1/config");if(o.ok&&(h=await o.json(),h&&h.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(o){console.warn("Could not fetch server config, checking localStorage fallback:",o.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}j();const g=document.getElementById("setup-timezone");g&&window.populateTimezoneSelect(g);function N(u){document.querySelectorAll(".setup-step").forEach(c=>{c.style.display="none"});const o=document.getElementById(u);o&&(o.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let o='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const d=document.getElementById("setup-tld")?.value?.trim()||".home",v=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",e=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;o+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${b}</div> <div><strong>TLD:</strong> ${d}</div>
<div><strong>Certificate Authority:</strong> ${L}</div> <div><strong>Certificate Authority:</strong> ${v}</div>
<div><strong>DNS Server:</strong> ${l}:${c}</div> <div><strong>DNS Server:</strong> ${p}:${e}</div>
<div><strong>Example URLs:</strong> https://uptime${b}, https://nextcloud${b}</div> <div><strong>Example URLs:</strong> https://uptime${d}, https://nextcloud${d}</div>
</div> </div>
</div> </div>
`}else if(w==="simple"){const b=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";v+=` `}else if(b==="simple"){const d=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";o+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Access Method:</strong> IP:Port only</div> <div><strong>Access Method:</strong> IP:Port only</div>
<div><strong>Default IP:</strong> ${b}</div> <div><strong>Default IP:</strong> ${d}</div>
<div><strong>SSL:</strong> None (HTTP only)</div> <div><strong>SSL:</strong> None (HTTP only)</div>
<div><strong>Example URLs:</strong> http://${b}:8080, http://${b}:3000</div> <div><strong>Example URLs:</strong> http://${d}:8080, http://${d}:3000</div>
</div> </div>
</div> </div>
`}else if(w==="public"){const b=document.getElementById("setup-public-domain")?.value?.trim()||"",L=document.getElementById("setup-public-email")?.value?.trim()||"",l=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",c=l==="subdirectory"?`https://${b}/sonarr, https://${b}/grafana`:`https://sonarr.${b}, https://grafana.${b}`;v+=` `}else if(b==="public"){const d=document.getElementById("setup-public-domain")?.value?.trim()||"",v=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=p==="subdirectory"?`https://${d}/sonarr, https://${d}/grafana`:`https://sonarr.${d}, https://grafana.${d}`;o+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${b}</div> <div><strong>Domain:</strong> ${d}</div>
<div><strong>SSL:</strong> Let's Encrypt</div> <div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${L}</div> <div><strong>Email:</strong> ${v}</div>
<div><strong>Routing:</strong> ${l==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div> <div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${c}</div> <div><strong>Example URLs:</strong> ${e}</div>
</div> </div>
</div> </div>
`}const I=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";v+=` `}const c=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";o+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);"> <div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${I.replace(/_/g," ")}</div> <div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${c.replace(/_/g," ")}</div>
</div> </div>
`,v+="</div>",m.innerHTML=v,N("setup-step-summary")}async function M(m){try{const v=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});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 R(){const m={setupComplete:!0,configurationType:w,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(w==="homelab"){m.tld=document.getElementById("setup-tld")?.value?.trim()||".home",m.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const L=document.getElementById("setup-dns-provider")?.value||"technitium";m.dns={provider:L,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()||""},m.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else w==="simple"?(m.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",m.defaults={dnsType:"none",sslType:"none",targetIP:m.defaultIP}):w==="public"&&(m.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",m.email=document.getElementById("setup-public-email")?.value?.trim()||"",m.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m.defaults={dnsType:m.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const v=await M(m);safeSet("dashcaddy-config",JSON.stringify(m)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const I=w==="homelab"?"Professional Home Lab":w==="simple"?"Simple Setup":"Public Server",b=v?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${I}. Settings saved to: ${b}`,"success",5e3),setTimeout(()=>location.reload(),500)}const h=document.getElementById("setup-step-1-next");h&&(h.onclick=function(m){m.preventDefault();const v=document.querySelector('input[name="config-type"]:checked');v&&(w=v.value),N(w==="homelab"?"setup-step-homelab":w==="simple"?"setup-step-simple":w==="public"?"setup-step-public":"setup-step-homelab")});const C=document.getElementById("setup-skip");C&&(C.onclick=async function(m){m.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 E=document.getElementById("setup-tld");E&&(E.oninput=function(m){const v=m.target.value||".home",I=document.getElementById("tld-preview"),b=document.getElementById("tld-preview-2");I&&(I.textContent=v),b&&(b.textContent=v)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(m){m.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(m){m.preventDefault();const v=document.getElementById("setup-tld")?.value?.trim()||"",I=document.getElementById("setup-ca-name")?.value?.trim()||"",b=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(!I){showNotification("Please enter a Certificate Authority name","warning");return}if(!b){showNotification("Please enter your DNS server IP address","warning");return}P()});const z=document.getElementById("setup-simple-back");z&&(z.onclick=function(m){m.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(m){m.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(m){m.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 T=document.getElementById("setup-public-back");T&&(T.onclick=function(m){m.preventDefault(),N("setup-step-1")});const D=document.getElementById("setup-public-next");D&&(D.onclick=function(m){m.preventDefault();const v=document.getElementById("setup-public-domain")?.value?.trim()||"",I=document.getElementById("setup-public-email")?.value?.trim()||"";if(!v){showNotification("Please enter your domain name","warning");return}if(!I||!I.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const y=document.getElementById("setup-summary-back");y&&(y.onclick=function(m){m.preventDefault(),w==="homelab"?N("setup-step-homela Line truncated `,o+="</div>",u.innerHTML=o,N("setup-step-summary")}async function M(u){try{const o=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return o.ok?(await o.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${o.status}`),{function:"saveConfigToServer"}),!1)}catch(o){return errorHandler.logError("[SetupWizard] Save Config",o,{function:"saveConfigToServer"}),!1}}async function R(){const u={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(b==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const v=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:v,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 b==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):b==="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 o=await M(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const c=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",d=o?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${d}`,"success",5e3),setTimeout(()=>location.reload(),500)}const f=document.getElementById("setup-step-1-next");f&&(f.onclick=function(u){u.preventDefault();const o=document.querySelector('input[name="config-type"]:checked');o&&(b=o.value),N(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.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 w=document.getElementById("setup-tld");w&&(w.oninput=function(u){const o=u.target.value||".home",c=document.getElementById("tld-preview"),d=document.getElementById("tld-preview-2");c&&(c.textContent=o),d&&(d.textContent=o)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(u){u.preventDefault(),N("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(u){u.preventDefault();const o=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",d=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!o||!o.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!d){showNotification("Please enter your DNS server IP address","warning");return}P()});const z=document.getElementById("setup-simple-back");z&&(z.onclick=function(u){u.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-simple-next");k&&(k.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var o=document.getElementById("dns-requirement-note");o&&(o.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const C=document.getElementById("setup-public-back");C&&(C.onclick=function(u){u.preventDefault(),N("setup-step-1")});const D=document.getElementById("setup-public-next");D&&(D.onclick=function(u){u.preventDefault();const o=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!o){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const y=document.getElementById("setup-summary-back");y&&(y.onclick=function(u){u.preventDefault(),b==="homelab"?N("setup-step-homela Line truncated
<div class="app-selector-content"> <div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2> <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> <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> </button>
</div> </div>
</div> </div>
</div>`);const x="custom-apps";let j=null,g=null;const N=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function M(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return j=c.templates,g=c.categories,!0}catch(l){w.logError("[AppSelector] Fetch Templates",l,{function:"fetchApiTemplates"})}return!1}async function R(l){try{return await(await fetch(`/api/v1/apps/ports/${l}/check`)).json()}catch(c){return w.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function h(l){try{const d=await(await fetch(`/api/v1/apps/ports/${l}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){w.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return l}async function C(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!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 l={};for(const[d,f]of Object.entries(j)){const p=f.category||"Other";l[p]||(l[p]=[]),l[p].push({id:d,...f})}const c=g?Object.keys(g):Object.keys(l).sort();for(const d of c){const f=l[d];if(!f||f.length===0)continue;f.sort((s,r)=>(r.popularity||0)-(s.popularity||0));const p=document.createElement("div");p.className="app-category-header";const e=g?.[d]||{};p.innerHTML=`${escapeHtml(e.icon||"")} ${escapeHtml(d)}`,e.color&&(p.style.borderBottomColor=e.color),P.appendChild(p),f.forEach(s=>{const r=document.createElement("div");r.className="app-option";const o=s.isDashboardWidget,a=o&&safeGet("widget-"+s.id+"-enabled")!=="false",t=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${a?"#2ecc7130":"#e74c3c30"}; color: ${a?"#2ecc71":"#e74c3c"}; font-weight: 600;">${a?"ON":"OFF"}</div>`:"",n=!o&&s.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${s.difficulty==="Easy"?"#2ecc71":s.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${s.difficulty==="Easy"?"#2ecc71":s.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(s.difficulty)}</div>`:"";r.innerHTML=` </div>`);const h="custom-apps";let j=null,g=null;const N=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function M(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return j=c.templates,g=c.categories,!0}catch(o){b.logError("[AppSelector] Fetch Templates",o,{function:"fetchApiTemplates"})}return!1}async function R(o){try{return await(await fetch(`/api/v1/apps/ports/${o}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function f(o){try{const d=await(await fetch(`/api/v1/apps/ports/${o}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return o}async function L(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!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 o={};for(const[d,v]of Object.entries(j)){const p=v.category||"Other";o[p]||(o[p]=[]),o[p].push({id:d,...v})}const c=g?Object.keys(g):Object.keys(o).sort();for(const d of c){const v=o[d];if(!v||v.length===0)continue;v.sort((i,l)=>(l.popularity||0)-(i.popularity||0));const p=document.createElement("div");p.className="app-category-header";const e=g?.[d]||{};p.innerHTML=`${escapeHtml(e.icon||"")} ${escapeHtml(d)}`,e.color&&(p.style.borderBottomColor=e.color),P.appendChild(p),v.forEach(i=>{const l=document.createElement("div");l.className="app-option";const s=i.isDashboardWidget,a=s&&safeGet("widget-"+i.id+"-enabled")!=="false",t=s?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${a?"#2ecc7130":"#e74c3c30"}; color: ${a?"#2ecc71":"#e74c3c"}; font-weight: 600;">${a?"ON":"OFF"}</div>`:"",n=!s&&i.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i.difficulty==="Easy"?"#2ecc71":i.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${i.difficulty==="Easy"?"#2ecc71":i.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(i.difficulty)}</div>`:"";l.innerHTML=`
<div class="app-option-icon">${escapeHtml(s.icon||"\u{1F4E6}")}</div> <div class="app-option-icon">${escapeHtml(i.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(s.name)}</div> <div class="app-option-name">${escapeHtml(i.name)}</div>
<div class="app-option-desc">${escapeHtml(s.description||"")}</div> <div class="app-option-desc">${escapeHtml(i.description||"")}</div>
${t}${n} ${t}${n}
`,o?r.onclick=()=>E(s,r):r.onclick=()=>H(s),P.appendChild(r)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function E(l,c){const d="widget-"+l.id+"-enabled",p=!(safeGet(d)!=="false");safeSet(d,String(p));const e=l.widgetSelector;if(e){const r=document.querySelector(e);r&&(r.style.display=p?"":"none")}const s=c.querySelector('div[style*="border-radius: 4px"]');s&&(s.textContent=p?"ON":"OFF",s.style.background=p?"#2ecc7130":"#e74c3c30",s.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${l.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function H(l){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),e=document.getElementById("deploy-ip"),s=document.getElementById("deploy-port"),r=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const V=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:l.id})})).json();if(V.success&&V.exists){const Y=V.container;confirm(`Found existing ${l.name} container: `,s?l.onclick=()=>w(i,l):l.onclick=()=>H(i),P.appendChild(l)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function w(o,c){const d="widget-"+o.id+"-enabled",p=!(safeGet(d)!=="false");safeSet(d,String(p));const e=o.widgetSelector;if(e){const l=document.querySelector(e);l&&(l.style.display=p?"":"none")}const i=c.querySelector('div[style*="border-radius: 4px"]');i&&(i.textContent=p?"ON":"OFF",i.style.background=p?"#2ecc7130":"#e74c3c30",i.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${o.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function H(o){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),v=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),e=document.getElementById("deploy-ip"),i=document.getElementById("deploy-port"),l=document.getElementById("deploy-tailscale-only"),s=document.getElementById("tailscale-status");try{const V=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:o.id})})).json();if(V.success&&V.exists){const Y=V.container;confirm(`Found existing ${o.name} container:
Container: ${Y.name} Container: ${Y.name}
Status: ${Y.status} Status: ${Y.status}
@@ -347,38 +347,38 @@ Port: ${Y.primaryPort||"N/A"}
Would you like to use this existing container? Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container. Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(l._useExisting=!0,l._existingContainer=Y)}}catch{}d.textContent=`Deploy ${l.name}`;const a=l.subdomain||l.id.replace(/-/g,"");f.value=a;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const W=l.subpathSupport||"strip";W==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+l.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(t.style.display="block",t.innerHTML='<span style="color: var(--muted);">&#9432; '+l.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const n=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),i=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),u=document.querySelector(`input[name="dns-type"][value="${n}"]`),A=document.querySelector(`input[name="ssl-type"][value="${i}"]`);u?u.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,A?A.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,e.value=SITE.defaults.targetIP||"localhost",r.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,V=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"),V&&!V.dataset.moved&&(F.appendChild(V),V.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),G=document.getElementById("media-path-description");if(l.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),V=document.getElementById("detected-mounts-list");try{const X=await(await fetch("/api/v1/media/detected-mounts")).json();if(X.success&&X.mounts.length>0){W.style.display="block",V.innerHTML="";const ae=[...new Set(X.mounts.map(te=>te.hostPath))];J.value=ae.join(", "),X.mounts.forEach(te=>{const Q=document.createElement("button");Q.type="button";const de=ae.includes(te.hostPath);Q.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);`,Q.innerHTML=`<span style="font-weight: 500;">${escapeHtml(te.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(te.sourceImage)}</span>`,Q.title=`${te.hostPath} (from ${te.sourceContainer})`,Q.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(te.hostPath);pe>=0?(le.splice(pe,1),Q.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(te.hostPath),Q.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},V.appendChild(Q)})}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 Z=document.getElementById("plex-claim-section");Z&&(l.id==="plex"||l.claimToken?(Z.style.display="block",document.getElementById("deploy-plex-claim").value=""):Z.style.display="none");const ee=document.getElementById("volume-mounts-section"),ne=document.getElementById("volume-mounts-list");if(ne.innerHTML="",l.docker?.volumes?.length){const W=l.mediaMount?.containerPath,V=l.docker.volumes.filter(Y=>!Y.includes("{{MEDIA_PATH}}")&&!(W&&Y.endsWith(":"+W)));V.length>0?(ee.style.display="block",V.forEach((Y,X)=>{const[ae,te]=Y.split(":"),Q=document.createElement("div");Q.style.cssText="display: flex; gap: 6px; align-items: center;",Q.innerHTML=` Click Cancel to deploy a new container.`)&&(o._useExisting=!0,o._existingContainer=Y)}}catch{}d.textContent=`Deploy ${o.name}`;const a=o.subdomain||o.id.replace(/-/g,"");v.value=a;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const W=o.subpathSupport||"strip";W==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+o.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(t.style.display="block",t.innerHTML='<span style="color: var(--muted);">&#9432; '+o.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const n=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),r=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),m=document.querySelector(`input[name="dns-type"][value="${n}"]`),A=document.querySelector(`input[name="ssl-type"][value="${r}"]`);m?m.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,A?A.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,e.value=SITE.defaults.targetIP||"localhost",l.checked=!1;const O=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),U=_?.querySelector("div");if(_&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,V=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1"),V&&!V.dataset.moved&&(U.appendChild(V),V.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),G=document.getElementById("media-path-description");if(o.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),V=document.getElementById("detected-mounts-list");try{const X=await(await fetch("/api/v1/media/detected-mounts")).json();if(X.success&&X.mounts.length>0){W.style.display="block",V.innerHTML="";const ae=[...new Set(X.mounts.map(te=>te.hostPath))];J.value=ae.join(", "),X.mounts.forEach(te=>{const Q=document.createElement("button");Q.type="button";const de=ae.includes(te.hostPath);Q.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);`,Q.innerHTML=`<span style="font-weight: 500;">${escapeHtml(te.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(te.sourceImage)}</span>`,Q.title=`${te.hostPath} (from ${te.sourceContainer})`,Q.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(te.hostPath);pe>=0?(le.splice(pe,1),Q.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(te.hostPath),Q.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},V.appendChild(Q)})}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 Z=document.getElementById("plex-claim-section");Z&&(o.id==="plex"||o.claimToken?(Z.style.display="block",document.getElementById("deploy-plex-claim").value=""):Z.style.display="none");const ee=document.getElementById("volume-mounts-section"),ne=document.getElementById("volume-mounts-list");if(ne.innerHTML="",o.docker?.volumes?.length){const W=o.mediaMount?.containerPath,V=o.docker.volumes.filter(Y=>!Y.includes("{{MEDIA_PATH}}")&&!(W&&Y.endsWith(":"+W)));V.length>0?(ee.style.display="block",V.forEach((Y,X)=>{const[ae,te]=Y.split(":"),Q=document.createElement("div");Q.style.cssText="display: flex; gap: 6px; align-items: center;",Q.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${te}" value="${ae}" <input type="text" class="vol-host-path" data-container-path="${te}" value="${ae}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" /> 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 ${te}</span> <span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${te}</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> <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>
`,ne.appendChild(Q),Q.querySelector(".vol-browse-btn").onclick=()=>{const de=Q.querySelector(".vol-host-path");openFolderBrowser(de)}})):ee.style.display="none"}else ee.style.display="none";const oe=l.defaultPort||8080;s.value="",s.placeholder=`Default: ${oe}`;let K=document.getElementById("deploy-port-status");K||(K=document.createElement("div"),K.id="deploy-port-status",K.style.cssText="font-size: 0.8rem; margin-top: 4px;",s.parentNode.appendChild(K));async function ie(){const W=s.value||oe;K.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const V=await R(W);if(V.available)K.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const Y=await h(oe);K.innerHTML=` `,ne.appendChild(Q),Q.querySelector(".vol-browse-btn").onclick=()=>{const de=Q.querySelector(".vol-host-path");openFolderBrowser(de)}})):ee.style.display="none"}else ee.style.display="none";const oe=o.defaultPort||8080;i.value="",i.placeholder=`Default: ${oe}`;let K=document.getElementById("deploy-port-status");K||(K=document.createElement("div"),K.id="deploy-port-status",K.style.cssText="font-size: 0.8rem; margin-top: 4px;",i.parentNode.appendChild(K));async function ie(){const W=i.value||oe;K.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const V=await R(W);if(V.available)K.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const Y=await f(oe);K.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(V.conflict?.usedBy||"unknown")}</span> <span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(V.conflict?.usedBy||"unknown")}</span>
`;const X=document.createElement("button");X.type="button",X.textContent=`Use ${Y}`,X.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",X.onclick=()=>{document.getElementById("deploy-port").value=Y,K.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(Y))}</span>`},K.appendChild(X)}}let re;s.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const V=await(await fetch("/api/v1/tailscale/status")).json();V.success&&V.installed&&V.connected?o.innerHTML=` `;const X=document.createElement("button");X.type="button",X.textContent=`Use ${Y}`,X.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",X.onclick=()=>{document.getElementById("deploy-port").value=Y,K.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(Y))}</span>`},K.appendChild(X)}}let re;i.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const V=await(await fetch("/api/v1/tailscale/status")).json();V.success&&V.installed&&V.connected?s.innerHTML=`
<span style="color: #4caf50;">Connected</span> <span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${V.self?.hostname} (${V.self?.ip})</span> <span style="color: var(--muted); margin-left: 8px;">${V.self?.hostname} (${V.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${V.deviceCount} devices</span> <span style="color: var(--muted); margin-left: 8px;">| ${V.deviceCount} devices</span>
`:V.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',r.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function se(){const W=f.value||"subdomain",V=document.querySelector('input[name="dns-type"]:checked').value,Y=document.querySelector('input[name="ssl-type"]:checked').value;let X="";if(SITE.routingMode==="subdirectory"&&SITE.domain)X=`https://${SITE.domain}/${W}`;else if(V==="private")X=`${Y==="none"?"http":"https"}://${buildDomain(W)}`;else if(V==="public"){const ae=Y==="none"?"http":"https",te=SITE.domain||W;X=SITE.domain?`${ae}://${W}.${SITE.domain}`:`${ae}://${W}`}else{const ae=s.value||l.defaultPort||DC.DEFAULTS.SERVICE_PORT;X=`http://${e.value}:${ae}`}p.textContent=X}f.oninput=se,e.oninput=se,s.oninput=se,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=se}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=se}),se(),N.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(l)}async function k(l){const c=l.appTemplate,d=safeGetJSON(x,[]),f=c._useExisting&&c._existingContainer,p=d.find(e=>e.id===l.subdomain);if(!(p&&!f&&!confirm(`An app with subdomain "${l.subdomain}" already exists. Redeploy?`))){if(p){const e=d.indexOf(p);d.splice(e,1),safeSet(x,JSON.stringify(d))}if(f)l.port=c._existingContainer.primaryPort;else{const e=l.port||c.defaultPort||8080;showNotification(`Checking port ${e} availability...`,"info",0);const s=await R(e);if(!s.available){const r=await h(c.defaultPort||8080);if(confirm(`Port ${e} is already in use by ${s.conflict?.usedBy||"another container"}. `:V.installed?s.innerHTML='<span style="color: #ff9800;">Not connected</span>':(s.innerHTML='<span style="color: var(--muted);">Not available</span>',l.disabled=!0)}catch{s.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function se(){const W=v.value||"subdomain",V=document.querySelector('input[name="dns-type"]:checked').value,Y=document.querySelector('input[name="ssl-type"]:checked').value;let X="";if(SITE.routingMode==="subdirectory"&&SITE.domain)X=`https://${SITE.domain}/${W}`;else if(V==="private")X=`${Y==="none"?"http":"https"}://${buildDomain(W)}`;else if(V==="public"){const ae=Y==="none"?"http":"https",te=SITE.domain||W;X=SITE.domain?`${ae}://${W}.${SITE.domain}`:`${ae}://${W}`}else{const ae=i.value||o.defaultPort||DC.DEFAULTS.SERVICE_PORT;X=`http://${e.value}:${ae}`}p.textContent=X}v.oninput=se,e.oninput=se,i.oninput=se,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=se}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=se}),se(),N.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(o)}async function x(o){const c=o.appTemplate,d=safeGetJSON(h,[]),v=c._useExisting&&c._existingContainer,p=d.find(e=>e.id===o.subdomain);if(!(p&&!v&&!confirm(`An app with subdomain "${o.subdomain}" already exists. Redeploy?`))){if(p){const e=d.indexOf(p);d.splice(e,1),safeSet(h,JSON.stringify(d))}if(v)o.port=c._existingContainer.primaryPort;else{const e=o.port||c.defaultPort||8080;showNotification(`Checking port ${e} availability...`,"info",0);const i=await R(e);if(!i.available){const l=await f(c.defaultPort||8080);if(confirm(`Port ${e} is already in use by ${i.conflict?.usedBy||"another container"}.
Would you like to use port ${r} instead?`))l.port=r;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const e={appId:c.id,config:{subdomain:l.subdomain,ip:l.ip,createDns:l.dnsType==="private",port:l.port||c.defaultPort||null,sslType:l.sslType,dnsType:l.dnsType,tailscaleOnly:l.tailscaleOnly||!1,mediaPath:l.mediaPath||null,plexClaimToken:l.plexClaimToken||null,customVolumes:l.customVolumes||null}};f&&(e.config.useExisting=!0,e.config.existingContainerId=c._existingContainer.id,e.config.existingPort=c._existingContainer.primaryPort,!l.port&&c._existingContainer.primaryPort&&(e.config.port=c._existingContainer.primaryPort));const s={app:c.id,port:l.port||c.defaultPort||null,subdomain:l.subdomain||"",media_path:l.mediaPath||"",env:l.plexClaimToken?{PLEX_CLAIM:l.plexClaimToken}:{}},o=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})).json();if(o.success){const a={id:l.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:null,url:o.url||`http://127.0.0.1:${s.port}`,ip:l.ip,appTemplate:c.id,tailscaleOnly:l.tailscaleOnly||!1};d.push(a),safeSet(x,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.id===c.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let t=o.usedExisting?`${c.name} configured with existing container! Would you like to use port ${l} instead?`))o.port=l;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(v?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const e={appId:c.id,config:{subdomain:o.subdomain,ip:o.ip,createDns:o.dnsType==="private",port:o.port||c.defaultPort||null,sslType:o.sslType,dnsType:o.dnsType,tailscaleOnly:o.tailscaleOnly||!1,mediaPath:o.mediaPath||null,plexClaimToken:o.plexClaimToken||null,customVolumes:o.customVolumes||null}};v&&(e.config.useExisting=!0,e.config.existingContainerId=c._existingContainer.id,e.config.existingPort=c._existingContainer.primaryPort,!o.port&&c._existingContainer.primaryPort&&(e.config.port=c._existingContainer.primaryPort));const i={app:c.id,port:o.port||c.defaultPort||null,subdomain:o.subdomain||"",media_path:o.mediaPath||"",env:o.plexClaimToken?{PLEX_CLAIM:o.plexClaimToken}:{}},s=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)})).json();if(s.success){const a={id:o.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:null,url:s.url||`http://127.0.0.1:${i.port}`,ip:o.ip,appTemplate:c.id,tailscaleOnly:o.tailscaleOnly||!1};d.push(a),safeSet(h,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.id===c.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let t=s.usedExisting?`${c.name} configured with existing container!
URL: ${o.url}`:`${c.name} deployed successfully! URL: ${s.url}`:`${c.name} deployed successfully!
URL: ${o.url}`;o.warning&&(t+=` URL: ${s.url}`;s.warning&&(t+=`
\u26A0 Warning: ${o.warning}`),showNotification(t,"success",8e3),delete c._useExisting,delete c._existingContainer,o.url&&o.url.startsWith("https://")&&z(o.url,c.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const n=o.setupInstructions.join(` \u26A0 Warning: ${s.warning}`),showNotification(t,"success",8e3),delete c._useExisting,delete c._existingContainer,s.url&&s.url.startsWith("https://")&&z(s.url,c.name),s.setupInstructions&&s.setupInstructions.length>0&&setTimeout(()=>{const n=s.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(e){w.logError("[AppSelector] Deployment",e,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${e.message}`,"error",8e3)}}}async function z(l,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const f=12,p=async()=>{d++;try{const e=await fetch(l,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<f?setTimeout(p,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating. `);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(s.error||"Deployment failed")}catch(e){b.logError("[AppSelector] Deployment",e,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${e.message}`,"error",8e3)}}}async function z(o,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const v=12,p=async()=>{d++;try{const e=await fetch(o,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<v?setTimeout(p,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(p,3e3)}function S(){safeGetJSON(x,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{C(),N.classList.add("show")}),window.openDeployConfigFor=async function(l){if(!j&&!await M())return!1;const c=String(l||"").toLowerCase(),d=Object.entries(j).find(([p])=>p.toLowerCase()===c);if(!d)return!1;const f={id:d[0],...d[1]};return f.isDashboardWidget?(await C(),N.classList.add("show"),"selector"):(await H(f),!0)},wireModal(N,document.getElementById("app-selector-cancel"));const T=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const l=JSON.parse(T.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{d.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const f={appTemplate:l,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(!f.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(l.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),k(f)}),wireModal(T);const D=document.getElementById("folder-browser-modal"),y=document.getElementById("folder-browser-path"),B=document.getElementById("folder-browser-list"),$=document.getElementById("folder-browser-selected"),U=document.getElementById("folder-browser-selected-list");let m="",v=[],I=null;window.openFolderBrowser=function(l){I=l,v=l.value.split(",").map(c=>c.trim()).filter(c=>c),m="",L(),b(""),D.classList.add("show")};async function b(l){y.textContent=l||"Select a drive...",B.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(l)}`)).json();if(!d.success){B.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}m=d.path||"",y.textContent=m||"Select a drive...";let f="";d.parent&&d.parent!==d.path&&(f+=`<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;"> Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(p,3e3)}function k(){safeGetJSON(h,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),window.openDeployConfigFor=async function(o){if(!j&&!await M())return!1;const c=String(o||"").toLowerCase(),d=Object.entries(j).find(([p])=>p.toLowerCase()===c);if(!d)return!1;const v={id:d[0],...d[1]};return v.isDashboardWidget?(await L(),N.classList.add("show"),"selector"):(await H(v),!0)},wireModal(N,document.getElementById("app-selector-cancel"));const C=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const o=JSON.parse(C.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{d.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const v={appTemplate:o,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(!v.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(o.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}C.classList.remove("show"),x(v)}),wireModal(C);const D=document.getElementById("folder-browser-modal"),y=document.getElementById("folder-browser-path"),T=document.getElementById("folder-browser-list"),$=document.getElementById("folder-browser-selected"),F=document.getElementById("folder-browser-selected-list");let S="",I=[],B=null;window.openFolderBrowser=function(o){B=o,I=o.value.split(",").map(c=>c.trim()).filter(c=>c),S="",u(),E(""),D.classList.add("show")};async function E(o){y.textContent=o||"Select a drive...",T.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(o)}`)).json();if(!d.success){T.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}S=d.path||"",y.textContent=S||"Select a drive...";let v="";d.parent&&d.parent!==d.path&&(v+=`<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="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span> <span style="color: var(--muted);">.. Parent Directory</span>
</div>`),d.items.length===0&&!d.parent?f+='<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?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(p=>{const e=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",s=v.includes(p.path),r=s?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${r}"> </div>`),d.items.length===0&&!d.parent?v+='<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?v+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(p=>{const e=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",i=I.includes(p.path),l=i?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";v+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${l}">
<span style="font-size: 1.2rem;">${e}</span> <span style="font-size: 1.2rem;">${e}</span>
<span style="flex: 1;">${escapeHtml(p.name)}</span> <span style="flex: 1;">${escapeHtml(p.name)}</span>
${s?'<span style="color: var(--success);">\u2713</span>':""} ${i?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),B.innerHTML=f,B.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{b(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const e=v.includes(p.dataset.path);p.style.background=e?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){B.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function L(){if(v.length===0){$.style.display="none";return}$.style.display="block",U.innerHTML=v.map(l=>` </div>`}),T.innerHTML=v,T.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{E(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const e=I.includes(p.dataset.path);p.style.background=e?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){T.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function u(){if(I.length===0){$.style.display="none";return}$.style.display="block",F.innerHTML=I.map(o=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;"> <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(l)} ${escapeHtml(o)}
<button type="button" data-remove-folder="${escapeHtml(l)}" aria-label="Remove ${escapeHtml(l)}" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button> <button type="button" data-remove-folder="${escapeHtml(o)}" aria-label="Remove ${escapeHtml(o)}" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span> </span>
`).join(""),U.querySelectorAll("[data-remove-folder]").forEach(l=>{l.addEventListener("click",()=>window.removeSelectedFolder(l.dataset.removeFolder))})}window.removeSelectedFolder=function(l){v=v.filter(c=>c!==l),L(),b(m)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{m&&!v.includes(m)&&(v.push(m),L(),b(m))}),wireModal(D,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{I&&(I.value=v.join(", ")),D.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal"> `).join(""),F.querySelectorAll("[data-remove-folder]").forEach(o=>{o.addEventListener("click",()=>window.removeSelectedFolder(o.dataset.removeFolder))})}window.removeSelectedFolder=function(o){I=I.filter(c=>c!==o),u(),E(S)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{S&&!I.includes(S)&&(I.push(S),u(),E(S))}),wireModal(D,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{B&&(B.value=I.join(", ")),D.classList.remove("show")}),k()})(),(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;"> <div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3> <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> <button id="recipe-next" class="btn-accent-solid">Next</button>
</div> </div>
</div> </div>
</div>`);let w=null,x=null,j=null,g=1,N=!1;const P=document.getElementById("recipe-deploy-modal"),M=document.getElementById("recipe-cancel"),R=document.getElementById("recipe-prev"),h=document.getElementById("recipe-next");wireModal(P,M);async function C(){try{const m=await fetch("/api/v1/recipes/templates"),v=await m.json();if(v.success)return w=v.templates,x=v.categories,!0;if(m.status===403)return N=!1,!1}catch(m){console.warn("Failed to fetch recipe templates:",m.message)}return!1}async function E(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(m){await E();let v;if(N&&w?v=w:v=H(),!v||v.length===0)return;const I=document.createElement("div");I.className="app-category-header",I.innerHTML="\u{1F9EA} Recipes",I.style.borderBottomColor="#8e44ad",m.appendChild(I);const b=Array.isArray(v)?v:Object.values(v);b.sort((L,l)=>(l.popularity||0)-(L.popularity||0));for(const L of b){const l=document.createElement("div");l.className="app-option",l.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;">${L.componentCount||L.components?.length||"?"} apps</div>`,d=N?"":'<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>';l.innerHTML=` </div>`);let b=null,h=null,j=null,g=1,N=!1;const P=document.getElementById("recipe-deploy-modal"),M=document.getElementById("recipe-cancel"),R=document.getElementById("recipe-prev"),f=document.getElementById("recipe-next");wireModal(P,M);async function L(){try{const S=await fetch("/api/v1/recipes/templates"),I=await S.json();if(I.success)return b=I.templates,h=I.categories,!0;if(S.status===403)return N=!1,!1}catch(S){console.warn("Failed to fetch recipe templates:",S.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(S){await w();let I;if(N&&b?I=b:I=H(),!I||I.length===0)return;const B=document.createElement("div");B.className="app-category-header",B.innerHTML="\u{1F9EA} Recipes",B.style.borderBottomColor="#8e44ad",S.appendChild(B);const E=Array.isArray(I)?I:Object.values(I);E.sort((u,o)=>(o.popularity||0)-(u.popularity||0));for(const u of E){const o=document.createElement("div");o.className="app-option",o.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;">${u.componentCount||u.components?.length||"?"} apps</div>`,d=N?"":'<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>';o.innerHTML=`
${d} ${d}
<div class="app-option-icon">${escapeHtml(L.icon||"\u{1F9EA}")}</div> <div class="app-option-icon">${escapeHtml(u.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(L.name)}</div> <div class="app-option-name">${escapeHtml(u.name)}</div>
<div class="app-option-desc">${escapeHtml(L.description||"")}</div> <div class="app-option-desc">${escapeHtml(u.description||"")}</div>
${c} ${c}
`,l.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(L)},m.appendChild(l)}};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(m){j=m,g=1;const v=document.getElementById("app-selector-modal");v&&v.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${m.name}`,z(),S(),P.classList.add("show")}function z(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(m=>{const v=parseInt(m.dataset.step);m.classList.toggle("active",v===g),m.classList.toggle("completed",v<g)});for(let m=1;m<=4;m++){const v=document.getElementById(`recipe-step-${m}`);v&&(v.style.display=m===g?"":"none")}R.style.display=g>1&&g<4?"":"none",g===4?(h.style.display="none",M.textContent="Close"):g===3?(h.textContent="\u{1F680} Deploy",h.style.display="",M.textContent="Cancel"):(h.textContent="Next",h.style.display="",M.textContent="Cancel")}function S(){const m=document.getElementById("recipe-component-list");m.innerHTML="";const v=j.components||[];for(const I of v){const b=document.createElement("div");b.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const L=I.required,l=I.internal;b.innerHTML=` `,o.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(u)},S.appendChild(o)}};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 x(S){j=S,g=1;const I=document.getElementById("app-selector-modal");I&&I.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${S.name}`,z(),k(),P.classList.add("show")}function z(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(S=>{const I=parseInt(S.dataset.step);S.classList.toggle("active",I===g),S.classList.toggle("completed",I<g)});for(let S=1;S<=4;S++){const I=document.getElementById(`recipe-step-${S}`);I&&(I.style.display=S===g?"":"none")}R.style.display=g>1&&g<4?"":"none",g===4?(f.style.display="none",M.textContent="Close"):g===3?(f.textContent="\u{1F680} Deploy",f.style.display="",M.textContent="Cancel"):(f.textContent="Next",f.style.display="",M.textContent="Cancel")}function k(){const S=document.getElementById("recipe-component-list");S.innerHTML="";const I=j.components||[];for(const B of I){const E=document.createElement("div");E.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const u=B.required,o=B.internal;E.innerHTML=`
<input type="checkbox" ${L?"checked disabled":"checked"} data-component-id="${I.id}" <input type="checkbox" ${u?"checked disabled":"checked"} data-component-id="${B.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" /> style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(I.role||I.id)}</div> <div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(B.role||B.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);"> <div style="font-size: 0.78rem; color: var(--muted);">
${I.templateRef?escapeHtml(I.templateRef):"Built-in"} ${B.templateRef?escapeHtml(B.templateRef):"Built-in"}
${L?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'} ${u?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${l?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""} ${o?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</div> </div>
${I.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(I.note)}</div>`:""} ${B.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(B.note)}</div>`:""}
</div> </div>
`,m.appendChild(b)}}function T(){const m=document.getElementById("recipe-volumes-section"),v=document.getElementById("recipe-volume-list"),I=j.sharedVolumes;if(I&&Object.keys(I).length>0){m.style.display="",v.innerHTML="";for(const[b,L]of Object.entries(I)){const l=document.createElement("div");l.style.cssText="display: grid; gap: 4px;",l.innerHTML=` `,S.appendChild(E)}}function C(){const S=document.getElementById("recipe-volumes-section"),I=document.getElementById("recipe-volume-list"),B=j.sharedVolumes;if(B&&Object.keys(B).length>0){S.style.display="",I.innerHTML="";for(const[E,u]of Object.entries(B)){const o=document.createElement("div");o.style.cssText="display: grid; gap: 4px;",o.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(L.label||b)}</label> <label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(u.label||E)}</label>
<input type="text" data-volume-key="${b}" value="${escapeHtml(L.defaultPath||"")}" <input type="text" data-volume-key="${E}" value="${escapeHtml(u.defaultPath||"")}"
placeholder="${escapeHtml(L.defaultPath||"/path")}" placeholder="${escapeHtml(u.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;" /> 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(L.description||"")}</div> <div class="form-hint-sm">${escapeHtml(u.description||"")}</div>
`,v.appendChild(l)}}else m.style.display="none"}function D(){const m=document.getElementById("recipe-review-content"),v=y(),I=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),b={};I.forEach(d=>{b[d.dataset.volumeKey]=d.value});const L=document.getElementById("recipe-timezone").value||"UTC",l=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;m.innerHTML=` `,I.appendChild(o)}}else S.style.display="none"}function D(){const S=document.getElementById("recipe-review-content"),I=y(),B=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),E={};B.forEach(d=>{E[d.dataset.volumeKey]=d.value});const u=document.getElementById("recipe-timezone").value||"UTC",o=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;S.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(j.name)}</div> <div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(j.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(j.description||"")}</div> <div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(j.description||"")}</div>
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<strong>Components (${v.length}):</strong> <strong>Components (${I.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;"> <div style="display: grid; gap: 4px; margin-top: 6px;">
${v.map(d=>`<div style="padding: 4px 0; font-size: 0.85rem;"> ${I.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>':""} \u2022 <strong>${escapeHtml(d.role||d.id)}</strong> ${d.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")} </div>`).join("")}
</div> </div>
</div> </div>
${Object.keys(b).length>0?`<div style="margin-bottom: 12px;"> ${Object.keys(E).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong> <strong>Volumes:</strong>
${Object.entries(b).map(([d,f])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(f)}</div>`).join("")} ${Object.entries(E).map(([d,v])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(v)}</div>`).join("")}
</div>`:""} </div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);"> <div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(L)} &bull; IP: ${escapeHtml(l)} ${c?"&bull; Tailscale only":""} Timezone: ${escapeHtml(u)} &bull; IP: ${escapeHtml(o)} ${c?"&bull; Tailscale only":""}
</div> </div>
${j.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(j.network.name)}</code></div>`:""} ${j.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(j.network.name)}</code></div>`:""}
`}function y(){const m=document.querySelectorAll("#recipe-component-list input[data-component-id]"),v=new Set;m.forEach(b=>{b.checked&&v.add(b.dataset.componentId)});const I=j.components||[];return I.filter(b=>b.required).forEach(b=>v.add(b.id)),I.filter(b=>v.has(b.id))}async function B(){const m=document.getElementById("recipe-progress-list"),v=document.getElementById("recipe-deploy-result");v.style.display="none",m.innerHTML="";const I=y();for(const c of I){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=` `}function y(){const S=document.querySelectorAll("#recipe-component-list input[data-component-id]"),I=new Set;S.forEach(E=>{E.checked&&I.add(E.dataset.componentId)});const B=j.components||[];return B.filter(E=>E.required).forEach(E=>I.add(E.id)),B.filter(E=>I.has(E.id))}async function T(){const S=document.getElementById("recipe-progress-list"),I=document.getElementById("recipe-deploy-result");I.style.display="none",S.innerHTML="";const B=y();for(const c of B){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 class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.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> <span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,m.appendChild(d)}const b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),L={};b.forEach(c=>{L[c.dataset.volumeKey]=c.value});const l={selectedComponents:I.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:L},componentOverrides:{}};for(const c of I)$(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:j.id,config:l})})).json();if(d.success){for(const f of d.deployed||[])$(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of d.errors||[])$(f.componentId,"error",f.error);v.style.display="",v.innerHTML=` `,S.appendChild(d)}const E=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),u={};E.forEach(c=>{u[c.dataset.volumeKey]=c.value});const o={selectedComponents:B.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:u},componentOverrides:{}};for(const c of B)$(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:j.id,config:o})})).json();if(d.success){for(const v of d.deployed||[])$(v.id,"success",v.url?`Running \u2192 ${v.url}`:"Running");for(const v of d.errors||[])$(v.componentId,"error",v.error);I.style.display="",I.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="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(d.message||"Deployed!")}</div> <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;"> ${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong> <strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(f=>`<li>${escapeHtml(f)}</li>`).join("")}</ul> <ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(v=>`<li>${escapeHtml(v)}</li>`).join("")}</ul>
</div>`:""} </div>`:""}
</div> </div>
`,showNotification(`${j.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);"> `,showNotification(`${j.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else I.style.display="",I.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")} <strong>Deployment failed:</strong> ${escapeHtml(d.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){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);"> </div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){I.style.display="",I.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)} <strong>Network error:</strong> ${escapeHtml(c.message)}
</div>`}}function $(m,v,I){const b=document.getElementById(`recipe-progress-${m}`);if(!b)return;const L=b.querySelector(".recipe-progress-icon"),l=b.querySelector(".recipe-progress-status");v==="deploying"?(L.textContent="\u23F3",l.style.color="var(--accent)"):v==="success"?(L.textContent="\u2705",l.style.color="var(--ok-fg)"):v==="error"&&(L.textContent="\u274C",l.style.color="var(--bad-fg)"),l.textContent=I}h.addEventListener("click",()=>{if(g===3){g=4,z(),B();return}g<3&&(g++,z(),g===2&&T(),g===3&&D())}),R.addEventListener("click",()=>{g>1&&g<4&&(g--,z())}),window.groupRecipeCards=function(){const m=document.querySelectorAll(".service-card[data-recipe-id]");if(m.length===0)return;const v={};m.forEach(I=>{const b=I.dataset.recipeId;v[b]||(v[b]=[]),v[b].push(I)});for(const[I,b]of Object.entries(v))b.length<2||b.forEach((L,l)=>{if(L.style.borderLeft="3px solid rgba(142,68,173,0.5)",l===0){let c=L.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=I.replace(/-/g," "),L.style.position="relative",L.appendChild(c))}})},window.manageRecipe=async function(m,v){const I=`/api/v1/recipes/${m}/${v}`,b=v==="remove"?"DELETE":"POST",L=v==="remove"?`/api/v1/recipes/${m}`:I;if(!(v==="remove"&&!confirm(`Remove the entire ${m} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(L,{method:b})).json();c.success?(showNotification(`Recipe ${v}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${v} failed: ${c.error}`,"error",5e3)}catch(l){showNotification(`Network error: ${l.message}`,"error",5e3)}};const U=document.createElement("style");U.textContent=` </div>`}}function $(S,I,B){const E=document.getElementById(`recipe-progress-${S}`);if(!E)return;const u=E.querySelector(".recipe-progress-icon"),o=E.querySelector(".recipe-progress-status");I==="deploying"?(u.textContent="\u23F3",o.style.color="var(--accent)"):I==="success"?(u.textContent="\u2705",o.style.color="var(--ok-fg)"):I==="error"&&(u.textContent="\u274C",o.style.color="var(--bad-fg)"),o.textContent=B}f.addEventListener("click",()=>{if(g===3){g=4,z(),T();return}g<3&&(g++,z(),g===2&&C(),g===3&&D())}),R.addEventListener("click",()=>{g>1&&g<4&&(g--,z())}),window.groupRecipeCards=function(){const S=document.querySelectorAll(".service-card[data-recipe-id]");if(S.length===0)return;const I={};S.forEach(B=>{const E=B.dataset.recipeId;I[E]||(I[E]=[]),I[E].push(B)});for(const[B,E]of Object.entries(I))E.length<2||E.forEach((u,o)=>{if(u.style.borderLeft="3px solid rgba(142,68,173,0.5)",o===0){let c=u.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=B.replace(/-/g," "),u.style.position="relative",u.appendChild(c))}})},window.manageRecipe=async function(S,I){const B=`/api/v1/recipes/${S}/${I}`,E=I==="remove"?"DELETE":"POST",u=I==="remove"?`/api/v1/recipes/${S}`:B;if(!(I==="remove"&&!confirm(`Remove the entire ${S} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(u,{method:E})).json();c.success?(showNotification(`Recipe ${I}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${I} failed: ${c.error}`,"error",5e3)}catch(o){showNotification(`Network error: ${o.message}`,"error",5e3)}};const F=document.createElement("style");F.textContent=`
.recipe-step { .recipe-step {
flex: 1; flex: 1;
text-align: center; text-align: center;
@@ -550,7 +550,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel { .recipe-step-panel {
min-height: 180px; min-height: 180px;
} }
`,document.head.appendChild(U),E()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const w=document.getElementById("reload-caddy-top"),x=w.textContent;try{w.textContent="\u23F3 Reloading...",w.disabled=!0;const j=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),g=await j.json();if(j.ok&&g.success)w.textContent="\u2705 Reloaded!",setTimeout(()=>{w.textContent=x,w.disabled=!1},2e3);else throw new Error(g.error||"Reload failed")}catch(j){w.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${j.message}`,"error"),setTimeout(()=>{w.textContent=x,w.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal"> `,document.head.appendChild(F),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),h=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const j=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),g=await j.json();if(j.ok&&g.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=h,b.disabled=!1},2e3);else throw new Error(g.error||"Reload failed")}catch(j){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${j.message}`,"error"),setTimeout(()=>{b.textContent=h,b.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;"> <div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>\u{1F4CB} Error Logs</h3> <h3>\u{1F4CB} Error Logs</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -597,8 +597,8 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="error-log-close">Close</button> <button id="error-log-close">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("error-log-modal"),x=document.getElementById("view-error-logs"),j=document.getElementById("error-log-refresh"),g=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),M=document.getElementById("error-log-context"),R=document.getElementById("error-log-search"),h=document.getElementById("error-log-since"),C=document.getElementById("error-log-until"),E=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),k=document.getElementById("error-log-total"),z=50;let S=0,T=null,D=0,y=[];function B(L){if(!L)return null;const l=new Date(L);return isNaN(l.getTime())?null:l.toISOString()}async function $(){try{const L=await fetch("/api/v1/error-logs/contexts");if(!L.ok)return;const l=await L.json();if(!l.success||!Array.isArray(l.contexts))return;y=l.contexts;const c=M.value;M.innerHTML='<option value="">All</option>';for(const d of l.contexts){const f=document.createElement("option");f.value=d.name,f.textContent=`${d.name} (${d.count})`,M.appendChild(f)}c&&l.contexts.some(d=>d.name===c)&&(M.value=c)}catch{}}function U(){const L=new URLSearchParams;L.set("limit",String(z)),L.set("offset",String(S)),P.value&&L.set("level",P.value),M.value&&L.set("context",M.value);const l=B(h.value),c=B(C.value);l&&L.set("since",l),c&&L.set("until",c);const d=(R.value||"").trim();return d&&L.set("search",d),L}async function m(L){try{L?(T&&T.abort(),T=new AbortController):(T&&T.abort(),T=new AbortController,S=0,D++,E.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const l=D,c=U(),d=await fetch("/api/v1/error-logs?"+c.toString(),{signal:T.signal});if(!d.ok){E.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${d.status}</div>`,H.style.display="none",k.textContent="";return}const f=await d.json();if(!f.success){E.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.error||"unknown")}</div>`,H.style.display="none",k.textContent="";return}if(!L&&l!==D)return;const p=Array.isArray(f.logs)?f.logs:[];if(p.length===0&&!L){const s=f.filters&&(f.filters.level||f.filters.context||f.filters.search||f.filters.since||f.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";E.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(s)}</div>`,H.style.display="none",k.textContent=f.total?`${f.total} total`:"";return}let e="";L||(e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',e+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',e+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',e+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',e+='<th style="padding: 6px; text-align: left;">Message</th>',e+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',e+="</tr>");for(const s of p){const r=(s.level||"?").toUpperCase(),o=r==="ERR"?"var(--bad-fg)":r==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",a=s.timestamp?new Date(s.timestamp).toLocaleString():"\u2014",t=s.context||"\u2014",n=(s.error||"").split(` </div>`);const b=document.getElementById("error-log-modal"),h=document.getElementById("view-error-logs"),j=document.getElementById("error-log-refresh"),g=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),M=document.getElementById("error-log-context"),R=document.getElementById("error-log-search"),f=document.getElementById("error-log-since"),L=document.getElementById("error-log-until"),w=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),x=document.getElementById("error-log-total"),z=50;let k=0,C=null,D=0,y=[];function T(u){if(!u)return null;const o=new Date(u);return isNaN(o.getTime())?null:o.toISOString()}async function $(){try{const u=await fetch("/api/v1/error-logs/contexts");if(!u.ok)return;const o=await u.json();if(!o.success||!Array.isArray(o.contexts))return;y=o.contexts;const c=M.value;M.innerHTML='<option value="">All</option>';for(const d of o.contexts){const v=document.createElement("option");v.value=d.name,v.textContent=`${d.name} (${d.count})`,M.appendChild(v)}c&&o.contexts.some(d=>d.name===c)&&(M.value=c)}catch{}}function F(){const u=new URLSearchParams;u.set("limit",String(z)),u.set("offset",String(k)),P.value&&u.set("level",P.value),M.value&&u.set("context",M.value);const o=T(f.value),c=T(L.value);o&&u.set("since",o),c&&u.set("until",c);const d=(R.value||"").trim();return d&&u.set("search",d),u}async function S(u){try{u?(C&&C.abort(),C=new AbortController):(C&&C.abort(),C=new AbortController,k=0,D++,w.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const o=D,c=F(),d=await fetch("/api/v1/error-logs?"+c.toString(),{signal:C.signal});if(!d.ok){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${d.status}</div>`,H.style.display="none",x.textContent="";return}const v=await d.json();if(!v.success){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.error||"unknown")}</div>`,H.style.display="none",x.textContent="";return}if(!u&&o!==D)return;const p=Array.isArray(v.logs)?v.logs:[];if(p.length===0&&!u){const i=v.filters&&(v.filters.level||v.filters.context||v.filters.search||v.filters.since||v.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";w.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(i)}</div>`,H.style.display="none",x.textContent=v.total?`${v.total} total`:"";return}let e="";u||(e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',e+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',e+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',e+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',e+='<th style="padding: 6px; text-align: left;">Message</th>',e+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',e+="</tr>");for(const i of p){const l=(i.level||"?").toUpperCase(),s=l==="ERR"?"var(--bad-fg)":l==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",a=i.timestamp?new Date(i.timestamp).toLocaleString():"\u2014",t=i.context||"\u2014",n=(i.error||"").split(`
`)[0],i=s.request&&s.request.ip||"";e+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',e+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(s.timestamp||"")}">${escapeHtml(a)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${o}; font-weight: 600;">${escapeHtml(r)}</span></td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(t)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(n)}</td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(i)}</td>`,e+="</tr>",s.detail&&(e+=`<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(s.detail)}</pre></td></tr>`)}if(!L)e+="</table>",E.innerHTML=e;else{const s=E.querySelector("table");s&&s.insertAdjacentHTML("beforeend",e)}S+=p.length,H.style.display=f.hasMore?"":"none",k.textContent=`${f.total} total${f.hasMore?" (showing "+S+")":""}`,E.querySelectorAll(".error-log-row").forEach(s=>{s.dataset.wired||(s.dataset.wired="true",s.addEventListener("click",()=>{const r=s.nextElementSibling;r&&r.classList.contains("error-log-detail")&&(r.style.display=r.style.display==="none"?"":"none")}))})}catch(l){if(l&&l.name==="AbortError")return;E.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(l.message)}</div>`,k.textContent=""}}async function v(){if(confirm("Clear the entire error log? This cannot be undone."))try{const l=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();l.success?(await $(),m(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(l.error||"Clear failed"),"error",4e3)}catch(L){showNotification("\u274C "+L.message,"error",4e3)}}let I;function b(){P?.addEventListener("change",()=>m(!1)),M?.addEventListener("change",()=>m(!1)),R?.addEventListener("input",()=>{clearTimeout(I),I=setTimeout(()=>m(!1),250)});let L;[h,C].forEach(l=>{l?.addEventListener("change",()=>{clearTimeout(L),L=setTimeout(()=>m(!1),250)})}),j?.addEventListener("click",()=>m(!1)),H?.addEventListener("click",()=>m(!0)),g?.addEventListener("click",v),wireModal(w,N)}x?.addEventListener("click",async()=>{w?.classList.add("show"),await $(),m(!1)}),b()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;"> `)[0],r=i.request&&i.request.ip||"";e+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',e+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(i.timestamp||"")}">${escapeHtml(a)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${s}; font-weight: 600;">${escapeHtml(l)}</span></td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(t)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(n)}</td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(r)}</td>`,e+="</tr>",i.detail&&(e+=`<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(i.detail)}</pre></td></tr>`)}if(!u)e+="</table>",w.innerHTML=e;else{const i=w.querySelector("table");i&&i.insertAdjacentHTML("beforeend",e)}k+=p.length,H.style.display=v.hasMore?"":"none",x.textContent=`${v.total} total${v.hasMore?" (showing "+k+")":""}`,w.querySelectorAll(".error-log-row").forEach(i=>{i.dataset.wired||(i.dataset.wired="true",i.addEventListener("click",()=>{const l=i.nextElementSibling;l&&l.classList.contains("error-log-detail")&&(l.style.display=l.style.display==="none"?"":"none")}))})}catch(o){if(o&&o.name==="AbortError")return;w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(o.message)}</div>`,x.textContent=""}}async function I(){if(confirm("Clear the entire error log? This cannot be undone."))try{const o=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();o.success?(await $(),S(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(o.error||"Clear failed"),"error",4e3)}catch(u){showNotification("\u274C "+u.message,"error",4e3)}}let B;function E(){P?.addEventListener("change",()=>S(!1)),M?.addEventListener("change",()=>S(!1)),R?.addEventListener("input",()=>{clearTimeout(B),B=setTimeout(()=>S(!1),250)});let u;[f,L].forEach(o=>{o?.addEventListener("change",()=>{clearTimeout(u),u=setTimeout(()=>S(!1),250)})}),j?.addEventListener("click",()=>S(!1)),H?.addEventListener("click",()=>S(!0)),g?.addEventListener("click",I),wireModal(b,N)}h?.addEventListener("click",async()=>{b?.classList.add("show"),await $(),S(!1)}),E()})(),(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="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 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> <div>
@@ -648,14 +648,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> <button id="cl-close-btn" class="btn-secondary">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("container-logs-modal"),x=document.getElementById("cl-container-select"),j=document.getElementById("cl-log-content"),g=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),M=document.getElementById("cl-stream"),R=document.getElementById("cl-download"),h=document.getElementById("cl-clear-search"),C=document.getElementById("cl-close"),E=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),z=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),D=document.getElementById("cl-image"),y=document.getElementById("cl-status"),B=document.getElementById("cl-created");let $=null,U=[],m=[],v=null,I=!1,b=null;function L(n){if(!n)return"-";const i=new Date(n);return isNaN(i.getTime())?n:i.toLocaleString()}function l(n){if(!n)return"";const i=document.createElement("div");return i.textContent=n,i.innerHTML}function c(n,i){const u=n.stream==="stderr"?"log-stderr":"log-stdout",A=n.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return` </div>`);const b=document.getElementById("container-logs-modal"),h=document.getElementById("cl-container-select"),j=document.getElementById("cl-log-content"),g=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),M=document.getElementById("cl-stream"),R=document.getElementById("cl-download"),f=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),x=document.getElementById("cl-stream-indicator"),z=document.getElementById("cl-stream-text"),k=document.getElementById("cl-line-count"),C=document.getElementById("cl-filter-count"),D=document.getElementById("cl-image"),y=document.getElementById("cl-status"),T=document.getElementById("cl-created");let $=null,F=[],S=[],I=null,B=!1,E=null;function u(n){if(!n)return"-";const r=new Date(n);return isNaN(r.getTime())?n:r.toLocaleString()}function o(n){if(!n)return"";const r=document.createElement("div");return r.textContent=n,r.innerHTML}function c(n,r){const m=n.stream==="stderr"?"log-stderr":"log-stdout",A=n.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${u}" data-index="${i}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;"> <div class="log-entry ${m}" data-index="${r}" 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;">${i+1}</span> <span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${r+1}</span>
<span class="log-stream-icon" style="color: ${n.stream==="stderr"?"#f59e0b":"#22c55e"};">${A}</span> <span class="log-stream-icon" style="color: ${n.stream==="stderr"?"#f59e0b":"#22c55e"};">${A}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${l(n.text)}</span> <span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${o(n.text)}</span>
</div> </div>
`}function d(n,i=""){if(!n||n.length===0){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",T.textContent="0 filtered";return}if(U=n,m=i?n.filter(u=>u.text&&u.text.toLowerCase().includes(i.toLowerCase())):n,S.textContent=`${n.length} lines`,T.textContent=i?`${m.length} of ${n.length} shown`:`${n.length} shown`,m.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${l(i)}"</div>`;return}j.innerHTML=m.map((u,A)=>c(u,A)).join(""),j.scrollTop=j.scrollHeight}async function f(){try{const i=(await getJSON("/api/v1/logs/containers")).containers||[],u=x.value;x.innerHTML='<option value="">Select a container...</option>',i.forEach(A=>{const O=document.createElement("option");O.value=A.id,O.textContent=`${A.name} (${A.image.split(":")[0]}) - ${A.status}`,O.dataset.name=A.name,O.dataset.image=A.image,O.dataset.status=A.status,O.dataset.created=A.created,x.appendChild(O)}),u&&x.querySelector(`option[value="${u}"]`)&&(x.value=u,p(u))}catch(n){console.error("Failed to load containers:",n)}}function p(n){const i=x.querySelector(`option[value="${n}"]`);i&&(D.textContent=i.dataset.image||"-",y.textContent=i.dataset.status||"-",y.style.color=i.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",B.textContent=L(i.dataset.created))}async function e(){const n=x.value;if(!n){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}r(),$=n,p(n);const i=N.value,u=g.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const A=`/api/v1/logs/container/${n}${i!=="all"?`?tail=${i}`:""}`,O=await getJSON(A);O.logs&&O.logs.length>0?d(O.logs,u):(j.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",T.textContent="0 filtered")}catch(A){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${l(A.message)}</div>`}}function s(){const n=x.value;if(!n)return;r(),I=!0,M.textContent="\u23F9 Stop",H.style.display="flex",k.textContent="\u{1F7E2}",z.textContent="Connecting...";const i=`/api/v1/logs/stream/${n}`;v=new EventSource(i),v.onopen=()=>{k.textContent="\u{1F7E2}",z.textContent="Connected - streaming logs"},v.onmessage=u=>{try{const A=JSON.parse(u.data);if(A.error){k.textContent="\u{1F534}",z.textContent=`Error: ${A.error}`;return}U.push(A),m.push(A),S.textContent=`${U.length} lines`,T.textContent=`${m.length} shown`;const O=g.value.trim();if(!O||A.text&&A.text.toLowerCase().includes(O.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(A,m.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",j.appendChild(F),j.scrollTop=j.scrollHeight}}catch(A){console.error("Error parsing log:",A)}},v.onerror=()=>{k.textContent="\u{1F534}",z.textContent="Disconnected",I=!1,M.textContent="\u25B6 Stream"},w._eventSource=v}function r(){v&&(v.close(),v=null),w._eventSource&&(w._eventSource.close(),w._eventSource=null),I=!1,M.textContent="\u25B6 Stream",H.style.display="none"}function o(){if(!U||U.length===0){showNotification("No logs to download","error");return}const n=x.querySelector(`option[value="${$}"]`)?.dataset.name||$,i=new Date().toISOString().replace(/[:.]/g,"-"),u=`${n}-logs-${i}.txt`,A=U.map(q=>{const J=q.timestamp||"",G=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${G} ${q.text}`}).join(` `}function d(n,r=""){if(!n||n.length===0){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',k.textContent="0 lines",C.textContent="0 filtered";return}if(F=n,S=r?n.filter(m=>m.text&&m.text.toLowerCase().includes(r.toLowerCase())):n,k.textContent=`${n.length} lines`,C.textContent=r?`${S.length} of ${n.length} shown`:`${n.length} shown`,S.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${o(r)}"</div>`;return}j.innerHTML=S.map((m,A)=>c(m,A)).join(""),j.scrollTop=j.scrollHeight}async function v(){try{const r=(await getJSON("/api/v1/logs/containers")).containers||[],m=h.value;h.innerHTML='<option value="">Select a container...</option>',r.forEach(A=>{const O=document.createElement("option");O.value=A.id,O.textContent=`${A.name} (${A.image.split(":")[0]}) - ${A.status}`,O.dataset.name=A.name,O.dataset.image=A.image,O.dataset.status=A.status,O.dataset.created=A.created,h.appendChild(O)}),m&&h.querySelector(`option[value="${m}"]`)&&(h.value=m,p(m))}catch(n){console.error("Failed to load containers:",n)}}function p(n){const r=h.querySelector(`option[value="${n}"]`);r&&(D.textContent=r.dataset.image||"-",y.textContent=r.dataset.status||"-",y.style.color=r.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",T.textContent=u(r.dataset.created))}async function e(){const n=h.value;if(!n){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}l(),$=n,p(n);const r=N.value,m=g.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const A=`/api/v1/logs/container/${n}${r!=="all"?`?tail=${r}`:""}`,O=await getJSON(A);O.logs&&O.logs.length>0?d(O.logs,m):(j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',k.textContent="0 lines",C.textContent="0 filtered")}catch(A){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${o(A.message)}</div>`}}function i(){const n=h.value;if(!n)return;l(),B=!0,M.textContent="\u23F9 Stop",H.style.display="flex",x.textContent="\u{1F7E2}",z.textContent="Connecting...";const r=`/api/v1/logs/stream/${n}`;I=new EventSource(r),I.onopen=()=>{x.textContent="\u{1F7E2}",z.textContent="Connected - streaming logs"},I.onmessage=m=>{try{const A=JSON.parse(m.data);if(A.error){x.textContent="\u{1F534}",z.textContent=`Error: ${A.error}`;return}F.push(A),S.push(A),k.textContent=`${F.length} lines`,C.textContent=`${S.length} shown`;const O=g.value.trim();if(!O||A.text&&A.text.toLowerCase().includes(O.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(A,S.length-1);const U=_.firstElementChild;U.style.background="#1a3a1a",j.appendChild(U),j.scrollTop=j.scrollHeight}}catch(A){console.error("Error parsing log:",A)}},I.onerror=()=>{x.textContent="\u{1F534}",z.textContent="Disconnected",B=!1,M.textContent="\u25B6 Stream"},b._eventSource=I}function l(){I&&(I.close(),I=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),B=!1,M.textContent="\u25B6 Stream",H.style.display="none"}function s(){if(!F||F.length===0){showNotification("No logs to download","error");return}const n=h.querySelector(`option[value="${$}"]`)?.dataset.name||$,r=new Date().toISOString().replace(/[:.]/g,"-"),m=`${n}-logs-${r}.txt`,A=F.map(q=>{const J=q.timestamp||"",G=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${G} ${q.text}`}).join(`
`),O=new Blob([A],{type:"text/plain"}),_=URL.createObjectURL(O),F=document.createElement("a");F.href=_,F.download=u,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${U.length} log lines`,"success")}x?.addEventListener("change",()=>{e()}),N?.addEventListener("change",()=>{e()}),P?.addEventListener("click",()=>{e()}),M?.addEventListener("click",()=>{I?r():s()}),R?.addEventListener("click",()=>{o()}),h?.addEventListener("click",()=>{g.value="",d(U,"")}),g?.addEventListener("input",()=>{clearTimeout(b),b=setTimeout(()=>{d(U,g.value.trim())},300)}),g?.addEventListener("keydown",n=>{n.key==="Escape"&&(g.value="",d(U,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{w.classList.add("show"),f()});function t(){r(),w.classList.remove("show")}C?.addEventListener("click",t),E?.addEventListener("click",t),document.addEventListener("keydown",n=>{n.key==="Escape"&&w.classList.contains("show")&&t()}),w.addEventListener("click",n=>{n.target===w&&t()}),window.openContainerLogsModal=function(n,i){w.classList.add("show"),f().then(()=>{const u=Array.from(x.options).find(A=>A.value===n||A.dataset.name===i);u?(x.value=u.value,p(u.value),e()):n?($=n,D.textContent=i||n,y.textContent="-",B.textContent="-",e()):j.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",` `),O=new Blob([A],{type:"text/plain"}),_=URL.createObjectURL(O),U=document.createElement("a");U.href=_,U.download=m,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(_),showNotification(`Downloaded ${F.length} log lines`,"success")}h?.addEventListener("change",()=>{e()}),N?.addEventListener("change",()=>{e()}),P?.addEventListener("click",()=>{e()}),M?.addEventListener("click",()=>{B?l():i()}),R?.addEventListener("click",()=>{s()}),f?.addEventListener("click",()=>{g.value="",d(F,"")}),g?.addEventListener("input",()=>{clearTimeout(E),E=setTimeout(()=>{d(F,g.value.trim())},300)}),g?.addEventListener("keydown",n=>{n.key==="Escape"&&(g.value="",d(F,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),v()});function t(){l(),b.classList.remove("show")}L?.addEventListener("click",t),w?.addEventListener("click",t),document.addEventListener("keydown",n=>{n.key==="Escape"&&b.classList.contains("show")&&t()}),b.addEventListener("click",n=>{n.target===b&&t()}),window.openContainerLogsModal=function(n,r){b.classList.add("show"),v().then(()=>{const m=Array.from(h.options).find(A=>A.value===n||A.dataset.name===r);m?(h.value=m.value,p(m.value),e()):n?($=n,D.textContent=r||n,y.textContent="-",T.textContent="-",e()):j.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 b=[{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 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="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 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;">
@@ -698,7 +698,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div> </div>
</div> </div>
</div> </div>
`);const x=document.getElementById("journald-modal"),j=document.getElementById("jd-unit-select"),g=document.getElementById("jd-search"),N=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),M=document.getElementById("jd-stream"),R=document.getElementById("jd-clear-search"),h=document.getElementById("jd-close"),C=document.getElementById("jd-close-btn"),E=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),k=document.getElementById("jd-filter-count"),z=document.getElementById("jd-overflow"),S=document.getElementById("jd-unit-display"),T=document.getElementById("jd-stream-state");let D=!1,y=[],B=!1,$=null,U=null;function m(p){const e=document.createElement("div");return e.textContent=String(p),e.innerHTML}function v(p){D=p,j.innerHTML="",w.forEach(e=>{const s=document.createElement("option");s.value=e.unit,s.textContent=e.label+" ("+e.unit+")",j.appendChild(s)}),j.disabled=!p,p?(P.disabled=!1,M.disabled=!1):(E.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 I(){try{const p=await fetch("/api/v1/logs/journal/units");if(!p.ok){v(!1);return}const e=await p.json();v(!!e.available)}catch{v(!1)}}function b(){const p=(g.value||"").trim().toLowerCase(),e=p?y.filter(s=>(s.textContent||"").toLowerCase().includes(p)):y;if(e.length===0)E.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(p?` matching &quot;${m(p)}&quot;`:"")+"</div>";else{const s=e.map(o=>{const a=o.timestamp?m(o.timestamp):"\u2014",t=m(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;">${a}</span>${t}</div>`}).join("");E.innerHTML=s,E.scrollHeight-E.scrollTop-E.clientHeight<80&&(E.scrollTop=E.scrollHeight)}H.textContent=`${y.length} entries`,k.textContent=p?`${e.length} of ${y.length} shown`:`${y.length} shown`}async function L(){if(!D)return;c();const p=j.value;if(!p)return;const e=Math.max(1,Math.min(5e3,Number(N.value)||200)),s=(g.value||"").trim();E.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const r=new URL("/api/v1/logs/journal",window.location.origin);r.searchParams.set("unit",p),r.searchParams.set("tail",String(e)),s&&r.searchParams.set("search",s);const o=await fetch(r.toString()),a=await o.json();if(!o.ok||!a.success){E.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+m(a&&a.error||"HTTP "+o.status)+"</div>";return}S.textContent=p,y=(a.entries||[]).map(t=>({timestamp:t.timestamp,unit:t.unit,textContent:t.text||""})),z.style.display="none",b()}catch(r){E.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+m(r.message)+"</div>"}}function l(){if(!D)return;c();const p=j.value;if(!p)return;const e=(g.value||"").trim();S.textContent=p,M.textContent="\u23F8 Stop",M.classList.add("streaming"),T.textContent="streaming",T.style.color="var(--ok-fg, #4ade80)",y=[],b(),z.style.display="none";const s=new URL("/api/v1/logs/journal/stream",window.location.origin);s.searchParams.set("unit",p),e&&s.searchParams.set("search",e),$=new EventSource(s.toString()),$.onmessage=r=>{try{const o=JSON.parse(r.data);if(o.error){/stream (exceeded|line cap)/.test(o.error)&&(z.style.display="",c()),E.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+m(o.error)+"</div>",E.scrollTop=E.scrollHeight;return}y.push({timestamp:o.timestamp,unit:o.unit||p,textContent:o.text||""}),y.length>5e3&&(y=y.slice(y.length-5e3),z.style.display=""),b()}catch{}},$.onerror=()=>{},B=!0}function c(){if(B=!1,$){try{$.close()}catch{}$=null}M.textContent="\u25B6 Stream",M.classList.remove("streaming"),T.textContent="disconnected",T.style.color="var(--muted)"}function d(){c(),x.classList.remove("show")}P.addEventListener("click",L),M.addEventListener("click",()=>B?c():l()),R.addEventListener("click",()=>{g.value="",b()}),g.addEventListener("input",()=>{clearTimeout(U),U=setTimeout(b,200)}),g.addEventListener("keydown",p=>{p.key==="Escape"&&(g.value="",b())}),h.addEventListener("click",d),C.addEventListener("click",d),x.addEventListener("click",p=>{p.target===x&&d()}),document.addEventListener("keydown",p=>{p.key==="Escape"&&x.classList.contains("show")&&d()}),j.addEventListener("change",()=>{y.length>0&&L()});function f(){x.classList.add("show"),I()}window.openJournaldModal=f,document.getElementById("view-journald-logs")?.addEventListener("click",f)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal"> `);const h=document.getElementById("journald-modal"),j=document.getElementById("jd-unit-select"),g=document.getElementById("jd-search"),N=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),M=document.getElementById("jd-stream"),R=document.getElementById("jd-clear-search"),f=document.getElementById("jd-close"),L=document.getElementById("jd-close-btn"),w=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),x=document.getElementById("jd-filter-count"),z=document.getElementById("jd-overflow"),k=document.getElementById("jd-unit-display"),C=document.getElementById("jd-stream-state");let D=!1,y=[],T=!1,$=null,F=null;function S(p){const e=document.createElement("div");return e.textContent=String(p),e.innerHTML}function I(p){D=p,j.innerHTML="",b.forEach(e=>{const i=document.createElement("option");i.value=e.unit,i.textContent=e.label+" ("+e.unit+")",j.appendChild(i)}),j.disabled=!p,p?(P.disabled=!1,M.disabled=!1):(w.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 B(){try{const p=await fetch("/api/v1/logs/journal/units");if(!p.ok){I(!1);return}const e=await p.json();I(!!e.available)}catch{I(!1)}}function E(){const p=(g.value||"").trim().toLowerCase(),e=p?y.filter(i=>(i.textContent||"").toLowerCase().includes(p)):y;if(e.length===0)w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(p?` matching &quot;${S(p)}&quot;`:"")+"</div>";else{const i=e.map(s=>{const a=s.timestamp?S(s.timestamp):"\u2014",t=S(s.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${a}</span>${t}</div>`}).join("");w.innerHTML=i,w.scrollHeight-w.scrollTop-w.clientHeight<80&&(w.scrollTop=w.scrollHeight)}H.textContent=`${y.length} entries`,x.textContent=p?`${e.length} of ${y.length} shown`:`${y.length} shown`}async function u(){if(!D)return;c();const p=j.value;if(!p)return;const e=Math.max(1,Math.min(5e3,Number(N.value)||200)),i=(g.value||"").trim();w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const l=new URL("/api/v1/logs/journal",window.location.origin);l.searchParams.set("unit",p),l.searchParams.set("tail",String(e)),i&&l.searchParams.set("search",i);const s=await fetch(l.toString()),a=await s.json();if(!s.ok||!a.success){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+S(a&&a.error||"HTTP "+s.status)+"</div>";return}k.textContent=p,y=(a.entries||[]).map(t=>({timestamp:t.timestamp,unit:t.unit,textContent:t.text||""})),z.style.display="none",E()}catch(l){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+S(l.message)+"</div>"}}function o(){if(!D)return;c();const p=j.value;if(!p)return;const e=(g.value||"").trim();k.textContent=p,M.textContent="\u23F8 Stop",M.classList.add("streaming"),C.textContent="streaming",C.style.color="var(--ok-fg, #4ade80)",y=[],E(),z.style.display="none";const i=new URL("/api/v1/logs/journal/stream",window.location.origin);i.searchParams.set("unit",p),e&&i.searchParams.set("search",e),$=new EventSource(i.toString()),$.onmessage=l=>{try{const s=JSON.parse(l.data);if(s.error){/stream (exceeded|line cap)/.test(s.error)&&(z.style.display="",c()),w.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+S(s.error)+"</div>",w.scrollTop=w.scrollHeight;return}y.push({timestamp:s.timestamp,unit:s.unit||p,textContent:s.text||""}),y.length>5e3&&(y=y.slice(y.length-5e3),z.style.display=""),E()}catch{}},$.onerror=()=>{},T=!0}function c(){if(T=!1,$){try{$.close()}catch{}$=null}M.textContent="\u25B6 Stream",M.classList.remove("streaming"),C.textContent="disconnected",C.style.color="var(--muted)"}function d(){c(),h.classList.remove("show")}P.addEventListener("click",u),M.addEventListener("click",()=>T?c():o()),R.addEventListener("click",()=>{g.value="",E()}),g.addEventListener("input",()=>{clearTimeout(F),F=setTimeout(E,200)}),g.addEventListener("keydown",p=>{p.key==="Escape"&&(g.value="",E())}),f.addEventListener("click",d),L.addEventListener("click",d),h.addEventListener("click",p=>{p.target===h&&d()}),document.addEventListener("keydown",p=>{p.key==="Escape"&&h.classList.contains("show")&&d()}),j.addEventListener("change",()=>{y.length>0&&u()});function v(){h.classList.add("show"),B()}window.openJournaldModal=v,document.getElementById("view-journald-logs")?.addEventListener("click",v)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;"> <div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>\u{1F4BE} Container Snapshots</h3> <h3>\u{1F4BE} Container Snapshots</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -752,7 +752,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="snapshot-close">Close</button> <button id="snapshot-close">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("snapshot-modal"),x=document.getElementById("snapshot-btn"),j=document.getElementById("snapshot-close"),g=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),M=document.getElementById("snapshot-create-status");let R=null;async function h(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;g.innerHTML='<option value="">-- Select a container --</option>';for(const T of S.containers){const D=document.createElement("option");D.value=T.id,D.textContent=`${T.name||T.id} (${T.image||"unknown"})`,D.dataset.name=T.name,D.dataset.image=T.image,D.dataset.status=T.status,D.dataset.created=T.created,g.appendChild(D)}}catch(z){console.error("Failed to load containers:",z)}}function C(z){if(!z||!z.value){N.style.display="none",R=null;return}R=z.value,document.getElementById("snapshot-image").textContent=z.dataset.image||"-",document.getElementById("snapshot-status").textContent=z.dataset.status||"-",document.getElementById("snapshot-created").textContent=z.dataset.created?new Date(z.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=z.value.substring(0,12),N.style.display=""}async function E(){if(!R){M.textContent="Please select a container first",M.style.color="var(--bad-fg)";return}const z=document.getElementById("snapshot-name").value.trim();if(!z){M.textContent="Please enter a snapshot name",M.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",M.textContent="";try{const D=await(await fetch(`/api/v1/containers/${encodeURIComponent(R)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:z,leaveRunning:S})})).json();D.success?(M.textContent=`\u2713 Snapshot "${z}" created successfully`,M.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(M.textContent=`\u2717 Failed: ${D.error||"Unknown error"}`,M.style.color="var(--bad-fg)")}catch(T){M.textContent=`\u2717 Error: ${T.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"),N.style.display="none",R=null,g.selectedIndex=0}x?.addEventListener("click",H),j?.addEventListener("click",k),wireModal(w,j),g?.addEventListener("change",z=>{const S=g.options[g.selectedIndex];C(S)}),P?.addEventListener("click",E),w?.querySelectorAll(".panel-tab").forEach(z=>{z.addEventListener("click",()=>{w.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),w.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),z.classList.add("active"),w.querySelector(`#${z.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal"> </div>`);const b=document.getElementById("snapshot-modal"),h=document.getElementById("snapshot-btn"),j=document.getElementById("snapshot-close"),g=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),M=document.getElementById("snapshot-create-status");let R=null;async function f(){try{const k=await(await fetch("/api/v1/containers")).json();if(!k.success||!k.containers)return;g.innerHTML='<option value="">-- Select a container --</option>';for(const C of k.containers){const D=document.createElement("option");D.value=C.id,D.textContent=`${C.name||C.id} (${C.image||"unknown"})`,D.dataset.name=C.name,D.dataset.image=C.image,D.dataset.status=C.status,D.dataset.created=C.created,g.appendChild(D)}}catch(z){console.error("Failed to load containers:",z)}}function L(z){if(!z||!z.value){N.style.display="none",R=null;return}R=z.value,document.getElementById("snapshot-image").textContent=z.dataset.image||"-",document.getElementById("snapshot-status").textContent=z.dataset.status||"-",document.getElementById("snapshot-created").textContent=z.dataset.created?new Date(z.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=z.value.substring(0,12),N.style.display=""}async function w(){if(!R){M.textContent="Please select a container first",M.style.color="var(--bad-fg)";return}const z=document.getElementById("snapshot-name").value.trim();if(!z){M.textContent="Please enter a snapshot name",M.style.color="var(--bad-fg)";return}const k=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",M.textContent="";try{const D=await(await fetch(`/api/v1/containers/${encodeURIComponent(R)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:z,leaveRunning:k})})).json();D.success?(M.textContent=`\u2713 Snapshot "${z}" created successfully`,M.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(M.textContent=`\u2717 Failed: ${D.error||"Unknown error"}`,M.style.color="var(--bad-fg)")}catch(C){M.textContent=`\u2717 Error: ${C.message}`,M.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function H(){b.classList.add("show"),f()}function x(){b.classList.remove("show"),N.style.display="none",R=null,g.selectedIndex=0}h?.addEventListener("click",H),j?.addEventListener("click",x),wireModal(b,j),g?.addEventListener("change",z=>{const k=g.options[g.selectedIndex];L(k)}),P?.addEventListener("click",w),b?.querySelectorAll(".panel-tab").forEach(z=>{z.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(k=>k.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(k=>k.classList.remove("active")),z.classList.add("active"),b.querySelector(`#${z.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;"> <div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3> <h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;"> <p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -831,24 +831,24 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button> <button id="arr-setup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("arr-setup-modal"),x=document.getElementById("arr-setup-btn"),j=document.getElementById("arr-setup-cancel"),g=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),M=document.getElementById("smart-phase-progress"),R=document.getElementById("smart-phase-results"),h=document.getElementById("smart-detect-results"),C=document.getElementById("smart-credential-inputs"),E=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),z=document.getElementById("smart-retry-btn");let S=null;const T={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},D={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function y(b){N.style.display=b==="detect"?"block":"none",P.style.display=b==="credentials"?"block":"none",M.style.display=b==="progress"?"block":"none",R.style.display=b==="results"?"block":"none"}function B(b){const L={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"}},l=L[b]||L.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, ${l.bg} 20%, transparent); color: ${l.bg};">${l.icon} ${l.text}</span>`}async function $(){y("detect"),h.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,h.style.display="block";return}let L='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(S.services)){const f=T[c]||"\u{1F4E6}",p=D[c]||c,e=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>`:"",s=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",r=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";L+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`);const b=document.getElementById("arr-setup-modal"),h=document.getElementById("arr-setup-btn"),j=document.getElementById("arr-setup-cancel"),g=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),M=document.getElementById("smart-phase-progress"),R=document.getElementById("smart-phase-results"),f=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),z=document.getElementById("smart-retry-btn");let k=null;const C={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},D={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function y(E){N.style.display=E==="detect"?"block":"none",P.style.display=E==="credentials"?"block":"none",M.style.display=E==="progress"?"block":"none",R.style.display=E==="results"?"block":"none"}function T(E){const u={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"}},o=u[E]||u.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, ${o.bg} 20%, transparent); color: ${o.bg};">${o.icon} ${o.text}</span>`}async function $(){y("detect"),f.style.display="none";try{if(k=await(await fetch("/api/v1/arr/smart-detect")).json(),!k.success){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(k.error)}</div>`,f.style.display="block";return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(k.services)){const v=C[c]||"\u{1F4E6}",p=D[c]||c,e=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>`:"",i=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",l=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";u+=`<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;">${f}</span> <span style="font-size: 1.1rem;">${v}</span>
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${p}</div> <div style="font-weight: 500; font-size: 0.9rem;">${p}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;"> <div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${e} ${s} ${r} ${e} ${i} ${l}
</div> </div>
</div> </div>
${B(d.status)} ${T(d.status)}
</div>`}L+="</div>";const l=S.summary;L+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;"> </div>`}u+="</div>";const o=k.summary;u+=`<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(l.fullyConnected))}/${escapeHtml(String(l.totalDetected+(5-l.totalDetected)))} services detected &middot; ${escapeHtml(String(o.fullyConnected))}/${escapeHtml(String(o.totalDetected+(5-o.totalDetected)))} services detected &middot;
${escapeHtml(String(l.fullyConnected))} connected${l.needsApiKey>0?` &middot; <strong>${escapeHtml(String(l.needsApiKey))} needs API key</strong>`:""} ${escapeHtml(String(o.fullyConnected))} connected${o.needsApiKey>0?` &middot; <strong>${escapeHtml(String(o.needsApiKey))} needs API key</strong>`:""}
</div>`,h.innerHTML=L,h.style.display="block",U(S),setTimeout(()=>{y("credentials")},800)}catch(b){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(b.message)}</div>`,h.style.display="block"}}function U(b){let L="";const l=b.services,c=["radarr","sonarr","prowlarr"];for(const p of c){const e=l[p];if(!e||e.status==="not_found"&&!e.url)continue;const s=T[p],r=D[p],o=e.status==="connected";L+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};"> </div>`,f.innerHTML=u,f.style.display="block",F(k),setTimeout(()=>{y("credentials")},800)}catch(E){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(E.message)}</div>`,f.style.display="block"}}function F(E){let u="";const o=E.services,c=["radarr","sonarr","prowlarr"];for(const p of c){const e=o[p];if(!e||e.status==="not_found"&&!e.url)continue;const i=C[p],l=D[p],s=e.status==="connected";u+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${s?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${s}</span> <span style="font-size: 1.1rem;">${i}</span>
<span style="font-weight: 500;">${r}</span> <span style="font-weight: 500;">${l}</span>
<span id="smart-${p}-status" style="margin-left: auto; font-size: 0.75rem;"> <span id="smart-${p}-status" style="margin-left: auto; font-size: 0.75rem;">
${o?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""} ${s?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span> </span>
</div> </div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;"> <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
@@ -859,45 +859,45 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div> </div>
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label> <label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${p}-key" placeholder="${o?"(saved)":"Paste API key"}" <input type="password" id="smart-${p}-key" placeholder="${s?"(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;" /> 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>
</div> </div>
<button type="button" data-smart-test="${p}" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button> <button type="button" data-smart-test="${p}" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const d=l.plex;if(d){const p=d.status==="connected";L+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;"> </div>`}const d=o.plex;if(d){const p=d.status==="connected";u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span> <span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span> <span style="font-weight: 500;">Plex</span>
${B(d.status)} ${T(d.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span>
</div> </div>
</div>`}const f=l.seerr;if(f){const p=f.status==="connected";let e="";if(f.configuredServices){const s=f.configuredServices;e=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;"> </div>`}const v=o.seerr;if(v){const p=v.status==="connected";let e="";if(v.configuredServices){const i=v.configuredServices;e=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${s.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot; Configured: ${i.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${s.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot; ${i.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${s.plex?"&#10003; Plex":"&#10007; Plex"} ${i.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}L+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"};"> </div>`}u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span> <span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span> <span style="font-weight: 500;">Seerr</span>
${B(f.status)} ${T(v.status)}
</div> </div>
${e} ${e}
</div>`}C.innerHTML=L,C.querySelectorAll("[data-smart-test]").forEach(p=>{p.addEventListener("click",()=>window.smartTestConnection(p.dataset.smartTest))})}window.smartTestConnection=async function(b){const L=document.getElementById(`smart-${b}-url`),l=document.getElementById(`smart-${b}-key`),c=document.getElementById(`smart-${b}-status`),d=L?.value.trim(),f=l?.value.trim();if(!d||!f){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const e=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:b,url:d,apiKey:f})})).json();e.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(e.appName||"Connected")} v${escapeHtml(e.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(e.error)}</span>`}catch(p){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(p.message)}</span>`}};async function m(){y("progress"),E.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 b={};for(const l of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${l}-url`)?.value.trim(),d=document.getElementById(`smart-${l}-key`)?.value.trim();d&&c?b[l]={apiKey:d,url:c}:d&&(b[l]={apiKey:d})}const L={services:Object.keys(b).length>0?b: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(L)})).json();let d="";for(const f of c.steps||[]){const p=f.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',e=f.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;"> </div>`}L.innerHTML=u,L.querySelectorAll("[data-smart-test]").forEach(p=>{p.addEventListener("click",()=>window.smartTestConnection(p.dataset.smartTest))})}window.smartTestConnection=async function(E){const u=document.getElementById(`smart-${E}-url`),o=document.getElementById(`smart-${E}-key`),c=document.getElementById(`smart-${E}-status`),d=u?.value.trim(),v=o?.value.trim();if(!d||!v){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const e=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:E,url:d,apiKey:v})})).json();e.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(e.appName||"Connected")} v${escapeHtml(e.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(e.error)}</span>`}catch(p){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(p.message)}</span>`}};async function S(){y("progress"),w.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 E={};for(const o of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${o}-url`)?.value.trim(),d=document.getElementById(`smart-${o}-key`)?.value.trim();d&&c?E[o]={apiKey:d,url:c}:d&&(E[o]={apiKey:d})}const u={services:Object.keys(E).length>0?E: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(u)})).json();let d="";for(const v of c.steps||[]){const p=v.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',e=v.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;">
${p} ${p}
<span>${escapeHtml(f.step)}</span> <span>${escapeHtml(v.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${e};">${escapeHtml(f.details||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: ${e};">${escapeHtml(v.details||"")}</span>
</div>`}E.innerHTML=d,setTimeout(()=>v(c),500)}catch(l){E.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(l.message)}</div>`}}function v(b){y("results");const L=b.summary||{},l=L.failed===0&&L.succeeded>0,c=l?"var(--ok-fg)":"#f39c12",d=l?"&#10003;":"&#9888;",f=l?"All Connected!":`${escapeHtml(String(L.succeeded))}/${escapeHtml(String(L.totalSteps))} Steps Succeeded`;let p=`<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>`}w.innerHTML=d,setTimeout(()=>I(c),500)}catch(o){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(o.message)}</div>`}}function I(E){y("results");const u=E.summary||{},o=u.failed===0&&u.succeeded>0,c=o?"var(--ok-fg)":"#f39c12",d=o?"&#10003;":"&#9888;",v=o?"All Connected!":`${escapeHtml(String(u.succeeded))}/${escapeHtml(String(u.totalSteps))} Steps Succeeded`;let p=`<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.5rem; color: ${c};">${d}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${f}</div> <div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${v}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(L.succeeded))} succeeded, ${escapeHtml(String(L.failed))} failed</div> <div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(u.succeeded))} succeeded, ${escapeHtml(String(u.failed))} failed</div>
</div>`;p+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const e of b.steps||[]){const s=e.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';p+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;"> </div>`;p+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const e of E.steps||[]){const i=e.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';p+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${s} ${escapeHtml(e.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(e.details||"")}</span> ${i} ${escapeHtml(e.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(e.details||"")}</span>
</div>`}p+="</div>",H.innerHTML=p,z.style.display=L.failed>0?"block":"none",b.steps?.some(e=>e.step.includes("Plex")&&e.status==="success")&&I()}async function I(){try{const L=await(await fetch("/api/v1/plex/libraries")).json();if(L.success&&L.libraries?.length>0){let l=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}p+="</div>",H.innerHTML=p,z.style.display=u.failed>0?"block":"none",E.steps?.some(e=>e.step.includes("Plex")&&e.status==="success")&&B()}async function B(){try{const u=await(await fetch("/api/v1/plex/libraries")).json();if(u.success&&u.libraries?.length>0){let o=`<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(L.serverName)} Libraries</h4> <h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(u.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of L.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";l+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;"> <div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of u.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";o+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${d} <strong>${escapeHtml(c.title)}</strong> ${d} <strong>${escapeHtml(c.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
</div>`}l+="</div></div>",k.innerHTML=l,k.style.display="block"}}catch{}}x?.addEventListener("click",()=>{w.classList.add("show"),k.style.display="none",$()}),wireModal(w,j),g?.addEventListener("click",m),z?.addEventListener("click",m)})(),(function(){const w=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal"> </div>`}o+="</div></div>",x.innerHTML=o,x.style.display="block"}}catch{}}h?.addEventListener("click",()=>{b.classList.add("show"),x.style.display="none",$()}),wireModal(b,j),g?.addEventListener("click",S),z?.addEventListener("click",S)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3> <h3>\u{1F514} Notification Settings</h3>
@@ -1109,15 +1109,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> <button id="notifications-save" class="btn-accent">Save Settings</button>
</div> </div>
</div> </div>
</div>`);const x=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),g=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const z=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);z?.addEventListener("change",()=>{S.style.display=z.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 R(){try{const z=await(await fetch("/api/v1/notifications/config")).json();if(z.success){const S=z.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),S.providers?.email?.to&&(document.getElementById("email-to").value=S.providers.email.to),S.providers?.email?.port&&(document.getElementById("email-port").value=S.providers.email.port),S.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=S.providers.email.secure===!0),S.providers?.email?.username&&(document.getElementById("email-user").value=S.providers.email.username);const T=document.getElementById("email-pass");S.providers?.email?.hasPassword?(T.value="",T.placeholder="saved \u2014 leave blank to keep"):T.placeholder="app password",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?.["container-down"]!==!1,document.getElementById("event-container-up").checked=S.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=S.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=S.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=S.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=S.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=S.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=S.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=S.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=S.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=S.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=S.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=S.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=S.events?.alert!==!1}}catch(k){w.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function h(){try{const z=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");z.success&&z.history?.length>0?S.innerHTML=z.history.map(T=>{const D=new Date(T.timestamp).toLocaleString();return` </div>`);const h=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),g=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const z=document.getElementById(`${x}-enabled`),k=document.getElementById(`${x}-config`);z?.addEventListener("change",()=>{k.style.display=z.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 R(){try{const z=await(await fetch("/api/v1/notifications/config")).json();if(z.success){const k=z.config;document.getElementById("notifications-enabled").checked=k.enabled,document.getElementById("discord-enabled").checked=k.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=k.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=k.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=k.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=k.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=k.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=k.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=k.providers?.email?.enabled?"block":"none",k.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=k.providers.ntfy.serverUrl),k.providers?.email?.host&&(document.getElementById("email-host").value=k.providers.email.host),k.providers?.email?.from&&(document.getElementById("email-from").value=k.providers.email.from),k.providers?.email?.to&&(document.getElementById("email-to").value=k.providers.email.to),k.providers?.email?.port&&(document.getElementById("email-port").value=k.providers.email.port),k.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=k.providers.email.secure===!0),k.providers?.email?.username&&(document.getElementById("email-user").value=k.providers.email.username);const C=document.getElementById("email-pass");k.providers?.email?.hasPassword?(C.value="",C.placeholder="saved \u2014 leave blank to keep"):C.placeholder="app password",document.getElementById("health-check-enabled").checked=k.healthCheck?.enabled||!1,k.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=k.healthCheck.intervalMinutes),k.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(k.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=k.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=k.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=k.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=k.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=k.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=k.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=k.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=k.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=k.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=k.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=k.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=k.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=k.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=k.events?.alert!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function f(){try{const z=await(await fetch("/api/v1/notifications/history?limit=10")).json(),k=document.getElementById("notification-history");z.success&&z.history?.length>0?k.innerHTML=z.history.map(C=>{const D=new Date(C.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;"> <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)"}[T.type]||"var(--muted)"}">${T.type==="success"?"\u2713":T.type==="error"?"\u2717":"\u2139"}</span> <span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[C.type]||"var(--muted)"}">${C.type==="success"?"\u2713":C.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(T.title)}</div> <div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(C.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${D}</div> <div style="font-size: 0.7rem; color: var(--muted);">${D}</div>
</div> </div>
</div> </div>
`}).join(""):S.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 C(){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,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert: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),x.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function E(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(z){showNotification(`Error: ${z.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>E("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>E("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>E("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>E("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const z=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();z.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(z.lastCheck).toLocaleString()} (${z.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{x.classList.add("show"),R(),h()}),g?.addEventListener("click",C),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),z=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const T=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();T.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${T.results?.map(D=>`${D.provider}: ${D.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=z,k.disabled=!1}});async function H(){try{const z=await(await fetch("/api/v1/notifications/status")).json();if(z.success&&z.lastSent){ Line truncated `}).join(""):k.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function L(){try{const x={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,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},k=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();k.success?(showNotification("Notification settings saved","success",3e3),h.classList.remove("show")):showNotification(`Failed to save: ${k.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function w(x){try{const k=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();k.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${k.error}`,"error",3e3)}catch(z){showNotification(`Error: ${z.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const z=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();z.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(z.lastCheck).toLocaleString()} (${z.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{h.classList.add("show"),R(),f()}),g?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const x=document.getElementById("notifications-send-test"),z=x.textContent;x.textContent="Sending...",x.disabled=!0;try{const C=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();C.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${C.results?.map(D=>`${D.provider}: ${D.error||"ok"}`).join(", ")}`,"error",5e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}finally{x.textContent=z,x.disabled=!1}});async function H(){try{const z=await(await fetch("/api/v1/notifications/status")).json();if(z.success&&z.lastSent){ Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3> <h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1223,9 +1223,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button> <button id="backup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);var P=document.getElementById("backup-modal"),M=document.getElementById("backup-restore-btn"),R=document.getElementById("backup-cancel"),h=document.getElementById("backup-export-btn"),C=document.getElementById("backup-select-file"),E=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),z=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),D=document.getElementById("backup-schedules-container");function y(r){!r||!r.querySelectorAll||r.querySelectorAll("[data-upgrade-msg]").forEach(o=>{o.addEventListener("click",()=>{showNotification(o.dataset.upgradeMsg,"info"),window.openLicenseModal&&window.openLicenseModal()})})}var B=document.getElementById("backup-history-container"),$=document.getElementById("backup-disk-container"),U=document.getElementById("pointintime-container"),m=null;M?.addEventListener("click",function(){P.classList.add("show"),T&&(T.style.display="none"),k&&(k.style.display="none"),H&&(H.style.display="none"),m=null}),wireModal(P,R),h?.addEventListener("click",async function(){h.disabled=!0,h.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var r=await fetch("/api/v1/backup/export"),o=await r.json();o.browserState=x();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),t=URL.createObjectURL(a),n=document.createElement("a");n.href=t,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(t);var i=Object.keys(o.browserState).length,u=o.themes?Object.keys(o.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(u?" + "+u+" themes":""),T.style.display="block",T.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",T.style.border="1px solid var(--ok-fg)"}catch(A){T.innerHTML="\u274C Export failed: "+escapeHtml(A.message),T.style.display="block",T.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",T.style.border="1px solid var(--bad-fg)"}h.disabled=!1,h.innerHTML="\u2B07\uFE0F Download Full Backup"}),C?.addEventListener("click",function(){E.click()}),E?.addEventListener("change",async function(r){var o=r.target.files[0];if(o){H.textContent="\u{1F4C4} "+o.name,H.style.display="block",T.style.display="none";try{var a=await o.text(),t=JSON.parse(a);if(g(t)){m=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),n+="</div>",z.innerHTML=n,k.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),u=await i.json();if(u.success){m=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.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 A in u.preview.files){var O=u.preview.files[A],_=O.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+_+" "+escapeHtml(O.description)+"</span>"}n+="</div>",u.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+u.preview.serviceCount+" services</div>"),u.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+u.preview.themeCount+" custom themes</div>"),u.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 '+u.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),z.innerHTML=n,k.style.display="block"}else T.innerHTML="\u2Line truncated </div>`);var P=document.getElementById("backup-modal"),M=document.getElementById("backup-restore-btn"),R=document.getElementById("backup-cancel"),f=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),z=document.getElementById("backup-preview-content"),k=document.getElementById("backup-do-restore-btn"),C=document.getElementById("backup-result"),D=document.getElementById("backup-schedules-container");function y(l){!l||!l.querySelectorAll||l.querySelectorAll("[data-upgrade-msg]").forEach(s=>{s.addEventListener("click",()=>{showNotification(s.dataset.upgradeMsg,"info"),window.openLicenseModal&&window.openLicenseModal()})})}var T=document.getElementById("backup-history-container"),$=document.getElementById("backup-disk-container"),F=document.getElementById("pointintime-container"),S=null;M?.addEventListener("click",function(){P.classList.add("show"),C&&(C.style.display="none"),x&&(x.style.display="none"),H&&(H.style.display="none"),S=null}),wireModal(P,R),f?.addEventListener("click",async function(){f.disabled=!0,f.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var l=await fetch("/api/v1/backup/export"),s=await l.json();s.browserState=h();var a=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),t=URL.createObjectURL(a),n=document.createElement("a");n.href=t,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(t);var r=Object.keys(s.browserState).length,m=s.themes?Object.keys(s.themes).length:0;C.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(m?" + "+m+" themes":""),C.style.display="block",C.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",C.style.border="1px solid var(--ok-fg)"}catch(A){C.innerHTML="\u274C Export failed: "+escapeHtml(A.message),C.style.display="block",C.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",C.style.border="1px solid var(--bad-fg)"}f.disabled=!1,f.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(l){var s=l.target.files[0];if(s){H.textContent="\u{1F4C4} "+s.name,H.style.display="block",C.style.display="none";try{var a=await s.text(),t=JSON.parse(a);if(g(t)){S=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),n+="</div>",z.innerHTML=n,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),m=await r.json();if(m.success){S=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.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 A in m.preview.files){var O=m.preview.files[A],_=O.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+_+" "+escapeHtml(O.description)+"</span>"}n+="</div>",m.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+m.preview.serviceCount+" services</div>"),m.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+m.preview.themeCount+" custom themes</div>"),m.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 '+m.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),z.innerHTML=n,x.style.display="block"}else C.innerHTML="\u2Line truncated
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(r)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),t=await a.json();t.success?(showNotification(r+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(t.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal"> This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(l)+"/revert/"+encodeURIComponent(s),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),t=await a.json();t.success?(showNotification(l+" restored to "+s,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(t.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;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F4CA} Resource Monitor</h3> <h3>\u{1F4CA} Resource Monitor</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1305,21 +1305,21 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button> <button id="stats-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("stats-modal"),x=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),g=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),M=document.getElementById("stats-aggregated-container"),R=document.getElementById("stats-alerts-container"),h=document.getElementById("stats-last-update");let C=null,E=null;function H(d){if(d===0||!d)return"0 B";const f=1024,p=["B","KB","MB","GB"],e=Math.floor(Math.log(d)/Math.log(f));return parseFloat((d/Math.pow(f,e)).toFixed(1))+" "+p[e]}function k(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function z(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function S(){try{let d=null,f=!1;try{const s=await(await fetch("/api/v1/monitoring/stats")).json();s.success&&s.stats&&(d=s.stats,f=!0,E=s.stats)}catch{}if(!f){const s=await(await fetch("/api/v1/stats/containers")).json();if(s.success&&s.stats){d={};for(const r of s.stats)d[r.name]={name:r.name,current:{cpu:r.cpu,memory:{percent:r.memory.percent,usage:r.memory.used,limit:r.memory.limit,usageMB:Math.round(r.memory.used/1048576),limitMB:Math.round(r.memory.limit/1048576)},network:{rxBytes:r.network.rx,txBytes:r.network.tx,rxMB:(r.network.rx/1048576).toFixed(1),txMB:(r.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:r.status};E=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 p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[e,s]of Object.entries(d)){const r=s.current||s,o=r.cpu?.percent||0,a=r.memory?.percent||0,t=k(o),n=z(a),i=r.memory?.usage||r.memory?.used||0,u=r.memory?.limit||0,A=r.network?.rxBytes||r.network?.rx||0,O=r.network?.txBytes||r.network?.tx||0,_=s.aggregated;p+=` </div>`);const b=document.getElementById("stats-modal"),h=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),g=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),M=document.getElementById("stats-aggregated-container"),R=document.getElementById("stats-alerts-container"),f=document.getElementById("stats-last-update");let L=null,w=null;function H(d){if(d===0||!d)return"0 B";const v=1024,p=["B","KB","MB","GB"],e=Math.floor(Math.log(d)/Math.log(v));return parseFloat((d/Math.pow(v,e)).toFixed(1))+" "+p[e]}function x(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function z(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function k(){try{let d=null,v=!1;try{const i=await(await fetch("/api/v1/monitoring/stats")).json();i.success&&i.stats&&(d=i.stats,v=!0,w=i.stats)}catch{}if(!v){const i=await(await fetch("/api/v1/stats/containers")).json();if(i.success&&i.stats){d={};for(const l of i.stats)d[l.name]={name:l.name,current:{cpu:l.cpu,memory:{percent:l.memory.percent,usage:l.memory.used,limit:l.memory.limit,usageMB:Math.round(l.memory.used/1048576),limitMB:Math.round(l.memory.limit/1048576)},network:{rxBytes:l.network.rx,txBytes:l.network.tx,rxMB:(l.network.rx/1048576).toFixed(1),txMB:(l.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:l.status};w=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 p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[e,i]of Object.entries(d)){const l=i.current||i,s=l.cpu?.percent||0,a=l.memory?.percent||0,t=x(s),n=z(a),r=l.memory?.usage||l.memory?.used||0,m=l.memory?.limit||0,A=l.network?.rxBytes||l.network?.rx||0,O=l.network?.txBytes||l.network?.tx||0,_=i.aggregated;p+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> <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;"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${s.name||e}</span> <span style="font-weight: 600; flex: 1;">${i.name||e}</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.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;">${s.status||"running"}</span> <span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${i.status||"running"}</span>
</div> </div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;"> <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
<div> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
<div style="display: flex; align-items: center; gap: 8px;"> <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="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(o,100)}%; background: ${t}; border-radius: 3px; transition: width 0.3s;"></div> <div style="height: 100%; width: ${Math.min(s,100)}%; background: ${t}; border-radius: 3px; transition: width 0.3s;"></div>
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${t}; min-width: 45px; text-align: right;">${o.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${t}; min-width: 45px; text-align: right;">${s.toFixed(1)}%</span>
</div> </div>
</div> </div>
<div> <div>
@@ -1330,7 +1330,7 @@ This will replace current configuration, credentials, and data. Containers will
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
</div> </div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(i)} / ${H(u)}</div> <div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(r)} / ${H(m)}</div>
</div> </div>
<div> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
@@ -1341,36 +1341,36 @@ This will replace current configuration, credentials, and data. Containers will
</div> </div>
</div> </div>
</div> </div>
</div>`}p+="</div>",P.innerHTML=p,h.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 T(){if(!M)return;const d=E;if(!d||Object.keys(d).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 f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[p,e]of Object.entries(d)){const s=e.aggregated;s&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}p+="</div>",P.innerHTML=p,f.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 C(){if(!M)return;const d=w;if(!d||Object.keys(d).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 v='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[p,e]of Object.entries(d)){const i=e.aggregated;i&&(v+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${e.name||p}</div> <div style="font-weight: 600; margin-bottom: 10px;">${e.name||p}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;"> <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${s.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div> <div class="stat-mini-card"><span class="stat-val">${i.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${s.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div> <div class="stat-mini-card"><span class="stat-val">${i.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${s.memory?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg Mem</span></div> <div class="stat-mini-card"><span class="stat-val">${i.memory?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg Mem</span></div>
<div class="stat-mini-card"><span class="stat-val">${s.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div> <div class="stat-mini-card"><span class="stat-val">${i.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div> </div>
${s.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${s.dataPoints} data points over ${s.timeRange||24}h</div>`:""} ${i.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${i.dataPoints} data points over ${i.timeRange||24}h</div>`:""}
</div>`)}f+="</div>",M.innerHTML=f}async function D(){if(!R)return;R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=E;if(!d||Object.keys(d).length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let p=[];try{const n=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();n.success&&(p=n.history||[])}catch{}let e={};try{const n=await(await fetch("/api/v1/monitoring/alerts/config")).json();n.success&&(e=n.configs||{})}catch{}const r=Object.entries(d).map(([t,n])=>{const i=e[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return` </div>`)}v+="</div>",M.innerHTML=v}async function D(){if(!R)return;R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=w;if(!d||Object.keys(d).length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let v=!1;try{v=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{v=!1}let p=[];try{const n=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();n.success&&(p=n.history||[])}catch{}let e={};try{const n=await(await fetch("/api/v1/monitoring/alerts/config")).json();n.success&&(e=n.configs||{})}catch{}const l=Object.entries(d).map(([t,n])=>{const r=e[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
<tr data-container="${t}"> <tr data-container="${t}">
<td style="font-weight: 600; padding: 8px;">${n.name||t}</td> <td style="font-weight: 600; padding: 8px;">${n.name||t}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${i.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${r.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${v?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${i.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${r.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${v?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${i.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${r.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${v?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${i.autoRestart?"checked":""} ${f?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${r.autoRestart?"checked":""} ${v?"":"disabled"} /></td>
<td style="padding: 4px 8px;"> <td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${t}" data-name="${n.name||t}" 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="${t}" data-name="${n.name||t}" 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> </td>
</tr> </tr>
`}).join(""),o=p.map(t=>{const n=new Date(t.timestamp).toLocaleString(),i=t.notified?"\u2713":"\u2014";return` `}).join(""),s=p.map(t=>{const n=new Date(t.timestamp).toLocaleString(),r=t.notified?"\u2713":"\u2014";return`
<tr> <tr>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${n}</td> <td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${n}</td>
<td style="padding: 6px 8px; font-weight: 500;">${t.containerName||t.containerId}</td> <td style="padding: 6px 8px; font-weight: 500;">${t.containerName||t.containerId}</td>
<td style="padding: 6px 8px; text-transform: capitalize;">${t.metric||t.type}</td> <td style="padding: 6px 8px; text-transform: capitalize;">${t.metric||t.type}</td>
<td style="padding: 6px 8px;">${typeof t.value=="number"?t.value.toFixed(1):t.value}${t.metric==="disk"?" MB/s":"%"}</td> <td style="padding: 6px 8px;">${typeof t.value=="number"?t.value.toFixed(1):t.value}${t.metric==="disk"?" MB/s":"%"}</td>
<td style="padding: 6px 8px; text-align: center;">${i}</td> <td style="padding: 6px 8px; text-align: center;">${r}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${t.autoRestartTriggered?"#f39c12":"var(--muted)"};">${t.autoRestartTriggered?"\u21BB":""}</td> <td style="padding: 6px 8px; font-size: 0.75rem; color: ${t.autoRestartTriggered?"#f39c12":"var(--muted)"};">${t.autoRestartTriggered?"\u21BB":""}</td>
</tr> </tr>
`}).join(""),a=f?` `}).join(""),a=v?`
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;"> <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> <h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
@@ -1388,7 +1388,7 @@ This will replace current configuration, credentials, and data. Containers will
<th style="padding: 6px 8px;"></th> <th style="padding: 6px 8px;"></th>
</tr> </tr>
</thead> </thead>
<tbody>${r}</tbody> <tbody>${l}</tbody>
</table> </table>
</div> </div>
<div style="margin-top: 12px; display: flex; justify-content: flex-end;"> <div style="margin-top: 12px; display: flex; justify-content: flex-end;">
@@ -1405,7 +1405,7 @@ This will replace current configuration, credentials, and data. Containers will
${a} ${a}
<div> <div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4> <h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
${o?` ${s?`
<div style="overflow-x: auto;"> <div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;"> <table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
<thead> <thead>
@@ -1418,26 +1418,26 @@ This will replace current configuration, credentials, and data. Containers will
<th style="padding: 6px 8px;"></th> <th style="padding: 6px 8px;"></th>
</tr> </tr>
</thead> </thead>
<tbody>${o}</tbody> <tbody>${s}</tbody>
</table> </table>
</div> </div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'} `:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div> </div>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(n=>{const i=n.dataset.container;t[i]={cpuThreshold:parseInt(n.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(n.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(n.querySelector(".alert-disk")?.value)||50,autoRestart:!!n.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const i=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),u=document.getElementById("save-all-alerts");u.textContent=i.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{u.textContent="Save All"},2e3)}catch{const i=document.getElementById("save-all-alerts");i.textContent="\u274C Error",setTimeout(()=>{i.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),w.classList.remove("show"),B(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.textContent;t.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${t.dataset.container}/test`,{method:"POST"}),t.textContent="\u2705",showNotification("Test alert sent for "+t.dataset.name,"success",3e3)}catch{t.textContent="\u274C"}setTimeout(()=>{t.textContent=n},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{w.classList.remove("show"),B(),typeof openLicenseModal=="function"&&openLicenseModal()})}function y(){C&&clearInterval(C),N?.checked&&(C=setInterval(S,DC.POLL.STATS))}function B(){C&&(clearInterval(C),C=null)}x?.addEventListener("click",()=>{w.classList.add("show"),S(),y()}),j?.addEventListener("click",()=>{w.classList.remove("show"),B()}),w?.addEventListener("click",d=>{d.target===w&&(w.classList.remove("show"),B())}),g?.addEventListener("click",S),N?.addEventListener("change",()=>{N.checked?y():B()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",D);const $=document.getElementById("stats-history-container"),U=document.getElementById("stats-history-container-area"),m=document.querySelectorAll(".stats-range-btn");let v="1h";function I(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 b(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function L(d,f,p,e,s){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const r=d.map(f).filter(q=>q!=null);if(r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const o=Math.max(...r,1),a=Math.min(...r,0),t=o-a||1,n=600,i=80,u=4,A=(n-u*2)/Math.max(r.length-1,1),O=r.map((q,J)=>{const G=u+J*A,Z=i-u-(q-a)/t*(i-u*2);return`${G.toFixed(1)},${Z.toFixed(1)}`}).join(" "),_=r[r.length-1],F=r.reduce((q,J)=>q+J,0)/r.length;return` `,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(n=>{const r=n.dataset.container;t[r]={cpuThreshold:parseInt(n.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(n.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(n.querySelector(".alert-disk")?.value)||50,autoRestart:!!n.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const r=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),m=document.getElementById("save-all-alerts");m.textContent=r.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{m.textContent="Save All"},2e3)}catch{const r=document.getElementById("save-all-alerts");r.textContent="\u274C Error",setTimeout(()=>{r.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),b.classList.remove("show"),T(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.textContent;t.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${t.dataset.container}/test`,{method:"POST"}),t.textContent="\u2705",showNotification("Test alert sent for "+t.dataset.name,"success",3e3)}catch{t.textContent="\u274C"}setTimeout(()=>{t.textContent=n},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{b.classList.remove("show"),T(),typeof openLicenseModal=="function"&&openLicenseModal()})}function y(){L&&clearInterval(L),N?.checked&&(L=setInterval(k,DC.POLL.STATS))}function T(){L&&(clearInterval(L),L=null)}h?.addEventListener("click",()=>{b.classList.add("show"),k(),y()}),j?.addEventListener("click",()=>{b.classList.remove("show"),T()}),b?.addEventListener("click",d=>{d.target===b&&(b.classList.remove("show"),T())}),g?.addEventListener("click",k),N?.addEventListener("change",()=>{N.checked?y():T()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",C),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",D);const $=document.getElementById("stats-history-container"),F=document.getElementById("stats-history-container-area"),S=document.querySelectorAll(".stats-range-btn");let I="1h";function B(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 E(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function u(d,v,p,e,i){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const l=d.map(v).filter(q=>q!=null);if(l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const s=Math.max(...l,1),a=Math.min(...l,0),t=s-a||1,n=600,r=80,m=4,A=(n-m*2)/Math.max(l.length-1,1),O=l.map((q,J)=>{const G=m+J*A,Z=r-m-(q-a)/t*(r-m*2);return`${G.toFixed(1)},${Z.toFixed(1)}`}).join(" "),_=l[l.length-1],U=l.reduce((q,J)=>q+J,0)/l.length;return`
<div style="margin-bottom: 14px;"> <div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;"> <div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(e)}</span> <span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(e)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${s} \xB7 avg ${F.toFixed(1)}${s} \xB7 max ${o.toFixed(1)}${s}</span> <span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${i} \xB7 avg ${U.toFixed(1)}${i} \xB7 max ${s.toFixed(1)}${i}</span>
</div> </div>
<svg viewBox="0 0 ${n} ${i}" preserveAspectRatio="none" style="width: 100%; height: ${i}px; background: var(--base); border-radius: 4px;"> <svg viewBox="0 0 ${n} ${r}" preserveAspectRatio="none" style="width: 100%; height: ${r}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${p}" stroke-width="1.5" points="${O}" /> <polyline fill="none" stroke="${p}" stroke-width="1.5" points="${O}" />
</svg> </svg>
</div> </div>
`}function l(){if(!$)return;const d=E||{},f=$.value,p=Object.entries(d);if(p.length===0){$.innerHTML='<option value="">No containers</option>';return}$.innerHTML=p.map(([e,s])=>`<option value="${escapeHtml(e)}">${escapeHtml(s.name||e)}</option>`).join(""),f&&d[f]&&($.value=f)}async function c(){if(!U||!$)return;const d=$.value;if(!d){U.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const f=Date.now(),p=f-I(v);U.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const s=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${p}&endTime=${f}`)).json();if(!s.success)throw new Error(s.error||"Failed to load history");const r=s.samples||[],o=s.tier||"raw";if(r.length===0){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${v}. Tier: ${b(o)}.</div>`;return}const a=o==="raw",t=a?O=>O.cpu?.percent:O=>O.cpu?.avg,n=a?O=>O.memory?.percent:O=>O.memory?.avgPercent,i=a?O=>O.network?.rxMB||0:O=>O.network?.rxMB||0,u=a?O=>O.network?.txMB||0:O=>O.network?.txMB||0;let A=` `}function o(){if(!$)return;const d=w||{},v=$.value,p=Object.entries(d);if(p.length===0){$.innerHTML='<option value="">No containers</option>';return}$.innerHTML=p.map(([e,i])=>`<option value="${escapeHtml(e)}">${escapeHtml(i.name||e)}</option>`).join(""),v&&d[v]&&($.value=v)}async function c(){if(!F||!$)return;const d=$.value;if(!d){F.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const v=Date.now(),p=v-B(I);F.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const i=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${p}&endTime=${v}`)).json();if(!i.success)throw new Error(i.error||"Failed to load history");const l=i.samples||[],s=i.tier||"raw";if(l.length===0){F.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${I}. Tier: ${E(s)}.</div>`;return}const a=s==="raw",t=a?O=>O.cpu?.percent:O=>O.cpu?.avg,n=a?O=>O.memory?.percent:O=>O.memory?.avgPercent,r=a?O=>O.network?.rxMB||0:O=>O.network?.rxMB||0,m=a?O=>O.network?.txMB||0:O=>O.network?.txMB||0;let A=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;"> <div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${r.length} samples \xB7 ${escapeHtml(b(o))} \xB7 ${new Date(p).toLocaleString()} \u2192 ${new Date(f).toLocaleString()} ${l.length} samples \xB7 ${escapeHtml(E(s))} \xB7 ${new Date(p).toLocaleString()} \u2192 ${new Date(v).toLocaleString()}
</div> </div>
`;A+=L(r,t,"#2ecc71","CPU","%"),A+=L(r,n,"#3498db","Memory","%"),A+=L(r,i,"#9b59b6","Network RX"," MB"),A+=L(r,u,"#e67e22","Network TX"," MB"),U.innerHTML=A}catch(e){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(e.message)}</div>`}}m.forEach(d=>{d.addEventListener("click",()=>{m.forEach(f=>f.classList.remove("active")),d.classList.add("active"),v=d.dataset.range,c()})}),$?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{l(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal"> `;A+=u(l,t,"#2ecc71","CPU","%"),A+=u(l,n,"#3498db","Memory","%"),A+=u(l,r,"#9b59b6","Network RX"," MB"),A+=u(l,m,"#e67e22","Network TX"," MB"),F.innerHTML=A}catch(e){F.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(e.message)}</div>`}}S.forEach(d=>{d.addEventListener("click",()=>{S.forEach(v=>v.classList.remove("active")),d.classList.add("active"),I=d.dataset.range,c()})}),$?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{o(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3> <h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1561,7 +1561,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button> <button id="health-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("health-modal"),x=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),g=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),M=document.getElementById("health-config-container"),R=document.getElementById("health-last-update"),h=document.getElementById("health-add-btn"),C=document.getElementById("health-config-form"),E=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save"),z="dashcaddy-health-settings",S={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},T=document.getElementById("health-global-save"),D=document.getElementById("health-global-reset"),y=document.getElementById("health-global-status"),B=document.getElementById("health-setting-retention"),$=document.getElementById("health-setting-interval"),U=document.getElementById("health-setting-stats-interval"),m=document.getElementById("health-setting-max-entries"),v=document.getElementById("health-setting-disk-threshold");function I(){try{const a=safeGet(z),t=a?JSON.parse(a):{};return Object.assign({},S,t)}catch{return Object.assign({},S)}}function b(){const a=I();B&&(B.value=a.retentionDays),$&&($.value=a.pollingInterval),U&&(U.value=a.statsPollingInterval),m&&(m.value=a.maxEntriesPerService),v&&(v.value=a.diskUsageThreshold)}function L(){const a={retentionDays:Math.max(1,Math.min(3650,parseInt(B?.value)||S.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt($?.value)||S.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(U?.value)||S.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(m?.value)||S.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(v?.value)||S.diskUsageThreshold))};try{safeSet(z,JSON.stringify(a)),b(),y&&(y.textContent="Saved \u2713",y.style.color="var(--ok-fg)",setTimeout(()=>{y&&(y.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(t){y&&(y.textContent="Save failed",y.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+t.message,"error")}}function l(){try{safeSet(z,JSON.stringify(S))}catch{}b(),y&&(y.textContent="Reset to defaults \u2713",y.style.color="var(--ok-fg)",setTimeout(()=>{y&&(y.textContent="")},2500))}b(),T?.addEventListener("click",L),D?.addEventListener("click",l);let c=null;function d(a){return a>=99.9?"var(--ok-fg)":a>=95?"#f39c12":"var(--bad-fg)"}function f(a){const t={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: ${t[a]||"var(--muted)"}20; color: ${t[a]||"var(--muted)"};">${a}</span>`}async function p(){try{const t=await(await fetch("/api/v1/health-checks/status")).json();if(!t.success||!t.status||Object.keys(t.status).length===0){N.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 n=Object.values(t.status);let i='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';i+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',i+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',i+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',i+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const u of n){const A=u.status==="up",O=A?"var(--dot-ok)":"var(--dot-bad)",_=u.uptime?.["24h"]??"-",F=u.uptime?.["7d"]??"-",q=u.avgResponseTime!=null?Math.round(u.avgResponseTime)+"ms":"-",J=u.timestamp?timeAgo(u.timestamp):"-";i+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(u.serviceId)}">`,i+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,i+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${O}; margin-right: 6px;"></span>${A?"Up":"Down"}</td>`,i+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,i+=`<td style="padding: 8px; color: ${typeof F=="number"?d(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,i+=`<td style="padding: 8px;">${q}</td>`,i+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,i+="</tr>",i+=`<tr id="health-detail-${escapeHtml(u.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 b=document.getElementById("health-modal"),h=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),g=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),M=document.getElementById("health-config-container"),R=document.getElementById("health-last-update"),f=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save"),z="dashcaddy-health-settings",k={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},C=document.getElementById("health-global-save"),D=document.getElementById("health-global-reset"),y=document.getElementById("health-global-status"),T=document.getElementById("health-setting-retention"),$=document.getElementById("health-setting-interval"),F=document.getElementById("health-setting-stats-interval"),S=document.getElementById("health-setting-max-entries"),I=document.getElementById("health-setting-disk-threshold");function B(){try{const a=safeGet(z),t=a?JSON.parse(a):{};return Object.assign({},k,t)}catch{return Object.assign({},k)}}function E(){const a=B();T&&(T.value=a.retentionDays),$&&($.value=a.pollingInterval),F&&(F.value=a.statsPollingInterval),S&&(S.value=a.maxEntriesPerService),I&&(I.value=a.diskUsageThreshold)}function u(){const a={retentionDays:Math.max(1,Math.min(3650,parseInt(T?.value)||k.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt($?.value)||k.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(F?.value)||k.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(S?.value)||k.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(I?.value)||k.diskUsageThreshold))};try{safeSet(z,JSON.stringify(a)),E(),y&&(y.textContent="Saved \u2713",y.style.color="var(--ok-fg)",setTimeout(()=>{y&&(y.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(t){y&&(y.textContent="Save failed",y.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+t.message,"error")}}function o(){try{safeSet(z,JSON.stringify(k))}catch{}E(),y&&(y.textContent="Reset to defaults \u2713",y.style.color="var(--ok-fg)",setTimeout(()=>{y&&(y.textContent="")},2500))}E(),C?.addEventListener("click",u),D?.addEventListener("click",o);let c=null;function d(a){return a>=99.9?"var(--ok-fg)":a>=95?"#f39c12":"var(--bad-fg)"}function v(a){const t={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: ${t[a]||"var(--muted)"}20; color: ${t[a]||"var(--muted)"};">${a}</span>`}async function p(){try{const t=await(await fetch("/api/v1/health-checks/status")).json();if(!t.success||!t.status||Object.keys(t.status).length===0){N.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 n=Object.values(t.status);let r='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';r+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',r+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',r+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',r+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of n){const A=m.status==="up",O=A?"var(--dot-ok)":"var(--dot-bad)",_=m.uptime?.["24h"]??"-",U=m.uptime?.["7d"]??"-",q=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",J=m.timestamp?timeAgo(m.timestamp):"-";r+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,r+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${O}; margin-right: 6px;"></span>${A?"Up":"Down"}</td>`,r+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,r+=`<td style="padding: 8px; color: ${typeof U=="number"?d(U):"var(--muted)"};">${typeof U=="number"?U.toFixed(1)+"%":U}</td>`,r+=`<td style="padding: 8px;">${q}</td>`,r+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,r+="</tr>",r+=`<tr id="health-detail-${escapeHtml(m.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 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>${q.totalChecks||0}</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);">Uptime</span><br><strong style="color: ${d(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
@@ -1571,14 +1571,14 @@ This will replace current configuration, credentials, and data. Containers will
<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);">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);">${q.upChecks||0}</strong></div> <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><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(a){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(a.message)}</div>`}}async function e(){try{const[a,t]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),n=await a.json(),i=await t.json();let u="";const A=n.success&&n.incidents?n.incidents:[];if(A.length>0){u+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+A.length+")</h4>";for(const _ of A)u+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);"> </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(a){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(a.message)}</div>`}}async function e(){try{const[a,t]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),n=await a.json(),r=await t.json();let m="";const A=n.success&&n.incidents?n.incidents:[];if(A.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+A.length+")</h4>";for(const _ of A)m+=`<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;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span> <span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
<span>${f(_.severity)}</span> <span>${v(_.severity)}</span>
</div> </div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(_.message)}</div> <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 style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(_.createdAt)} \xB7 ${_.occurrences||1} occurrence(s)</div>
</div>`;u+="</div>"}else u+='<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=i.success&&i.history?i.history:[];if(O.length>0){u+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',u+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',u+='<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":"-";u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,u+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,u+=`<td style="padding: 6px;">${f(_.severity)}</td>`,u+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,u+=`<td style="padding: 6px;">${q}</td>`,u+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,u+="</tr>"}u+="</table>"}P.innerHTML=u||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(a){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}async function s(){try{const t=await(await fetch("/api/v1/health-checks/status")).json(),n=t.success&&t.status?Object.values(t.status):[];if(n.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 i='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';i+='<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 u of n){const A=u.status==="up";i+='<tr style="border-bottom: 1px solid var(--border);">',i+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,i+=`<td style="padding: 8px; color: ${A?"var(--ok-fg)":"var(--bad-fg)"};">${A?"Up":"Down"}</td>`,i+=`<td style="padding: 8px;">${u.sla?.target?u.sla.target+"%":"-"}</td>`,i+='<td style="padding: 8px; text-align: right;">',i+=`<button data-health-action="health-edit" data-service-id="${escapeHtml(u.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,i+=`<button data-health-action="health-delete" data-service-id="${escapeHtml(u.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,i+="</td></tr>"}i+="</table>",M.innerHTML=i,M.querySelectorAll("[data-health-action]").forEach(u=>{u.addEventListener("click",()=>{document.dispatchEvent(new CustomEvent(u.dataset.healthAction,{detail:u.dataset.serviceId}))})})}catch(a){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}function r(a,t,n,i,u,A,O){c=a||null,E.textContent=a?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=a||"",document.getElementById("health-form-id").disabled=!!a,document.getElementById("health-form-name").value=t||"",document.getElementById("health-form-url").value=n||"",document.getElementById("health-form-timeout").value=i||1e4,document.getElementById("health-form-codes").value=u||"200",document.getElementById("health-form-sla").value=A||99.9,document.getElementById("health-form-slow").value=O||5e3,C.style.display="",h.style.display="none"}function o(){C.style.display="none",h.style.display="",c=null}h?.addEventListener("click",()=>r("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",o),k?.addEventListener("click",async()=>{const a=c||document.getElementById("health-form-id").value.trim();if(!a)return showNotification("Service ID is required","warning");const t=document.getElementById("health-form-url").value.trim();if(!t)return showNotification("URL is required","warning");const n=document.getElementById("health-form-codes").value.split(",").map(u=>parseInt(u.trim())).filter(Boolean),i={name:document.getElementById("health-form-name").value.trim()||a,url:t,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:n.length?n:[200],sla:{target:parseFloat(documenLine truncated </div>`;m+="</div>"}else m+='<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=r.success&&r.history?r.history:[];if(O.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<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 U=_.status==="resolved",q=U&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,m+=`<td style="padding: 6px;">${v(_.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${U?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,m+=`<td style="padding: 6px;">${q}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,m+="</tr>"}m+="</table>"}P.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(a){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}async function i(){try{const t=await(await fetch("/api/v1/health-checks/status")).json(),n=t.success&&t.status?Object.values(t.status):[];if(n.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 r='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';r+='<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 m of n){const A=m.status==="up";r+='<tr style="border-bottom: 1px solid var(--border);">',r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,r+=`<td style="padding: 8px; color: ${A?"var(--ok-fg)":"var(--bad-fg)"};">${A?"Up":"Down"}</td>`,r+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,r+='<td style="padding: 8px; text-align: right;">',r+=`<button data-health-action="health-edit" data-service-id="${escapeHtml(m.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,r+=`<button data-health-action="health-delete" data-service-id="${escapeHtml(m.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,r+="</td></tr>"}r+="</table>",M.innerHTML=r,M.querySelectorAll("[data-health-action]").forEach(m=>{m.addEventListener("click",()=>{document.dispatchEvent(new CustomEvent(m.dataset.healthAction,{detail:m.dataset.serviceId}))})})}catch(a){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}function l(a,t,n,r,m,A,O){c=a||null,w.textContent=a?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=a||"",document.getElementById("health-form-id").disabled=!!a,document.getElementById("health-form-name").value=t||"",document.getElementById("health-form-url").value=n||"",document.getElementById("health-form-timeout").value=r||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=A||99.9,document.getElementById("health-form-slow").value=O||5e3,L.style.display="",f.style.display="none"}function s(){L.style.display="none",f.style.display="",c=null}f?.addEventListener("click",()=>l("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",s),x?.addEventListener("click",async()=>{const a=c||document.getElementById("health-form-id").value.trim();if(!a)return showNotification("Service ID is required","warning");const t=document.getElementById("health-form-url").value.trim();if(!t)return showNotification("URL is required","warning");const n=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),r={name:document.getElementById("health-form-name").value.trim()||a,url:t,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:n.length?n:[200],sla:{target:parseFloat(documenLine truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3> <h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1652,17 +1652,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button> <button id="updates-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("updates-modal"),x=document.getElementById("updates-btn"),j=document.getElementById("updates-cancel"),g=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),R=document.getElementById("updates-last-check");async function h(){try{const e=await(await fetch("/api/v1/updates/available")).json();if(!e.success)throw new Error(e.error);const s=e.updates||[];if(s.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',R.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let r='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';r+='<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 t of s){const n=(()=>{const i=window.APPS||[];for(const u of i)if(u.containerId===t.containerId||u.name===t.containerName||u.id===t.containerName)return u.id;return t.containerName})();r+=`<tr data-app-id="${escapeHtml(n)}" style="border-bottom: 1px solid var(--border);">`,r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,r+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,r+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,r+=`<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(t.latestDigest)}</code></td>`,r+='<td style="padding: 8px; text-align: right;">',r+=`<button class="update-now-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,r+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,r+="</td></tr>"}r+="</table>",N.innerHTML=r,R.textContent=s.length+" update(s) available";const o=document.getElementById("updates-count-badge"),a=document.getElementById("updates-update-all-btn");o&&(o.textContent=s.length+" pending",o.style.display=""),a&&s.length>0&&(a.style.display=""),window._pendingUpdates=s,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.dataset.id,i=t.dataset.name;if(confirm(`Update "${i}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(n)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(A.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>h(),2e3);else throw new Error(A.error||"Update failed")}catch(u){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+u.message,"error"),setTimeout(()=>{t.textContent="Update",t.disabled=!1,t.style.color="",t.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.dataset.id,i=t.dataset.name;if(confirm(`Rollback "${i}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(n)}`,{method:"POST"})).json();if(A.success)t.textContent="Rolled back!",setTimeout(()=>h(),2e3);else throw new Error(A.error||"Rollback failed")}catch(u){t.textContent="Failed",showNotification("Rollback error: "+u.message,"error"),setTimeout(()=>{t.textContent="Rollback",t.disabled=!1},3e3)}}})})}catch(p){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function C(){const p=window._pendingUpdates||[];if(!p.length)return;const e=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${p.length} containers? Each will restart.`))return;e.textContent="\u23F3 Updating...",e.disabled=!0;let s=0,r=0;for(const o of p)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?s++:r++}catch{r++}e.textCLine truncated </div>`);const b=document.getElementById("updates-modal"),h=document.getElementById("updates-btn"),j=document.getElementById("updates-cancel"),g=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),R=document.getElementById("updates-last-check");async function f(){try{const e=await(await fetch("/api/v1/updates/available")).json();if(!e.success)throw new Error(e.error);const i=e.updates||[];if(i.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',R.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];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;">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 t of i){const n=(()=>{const r=window.APPS||[];for(const m of r)if(m.containerId===t.containerId||m.name===t.containerName||m.id===t.containerName)return m.id;return t.containerName})();l+=`<tr data-app-id="${escapeHtml(n)}" style="border-bottom: 1px solid var(--border);">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,l+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,l+=`<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(t.latestDigest)}</code></td>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<button class="update-now-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,l+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,l+="</td></tr>"}l+="</table>",N.innerHTML=l,R.textContent=i.length+" update(s) available";const s=document.getElementById("updates-count-badge"),a=document.getElementById("updates-update-all-btn");s&&(s.textContent=i.length+" pending",s.style.display=""),a&&i.length>0&&(a.style.display=""),window._pendingUpdates=i,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.dataset.id,r=t.dataset.name;if(confirm(`Update "${r}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(n)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(A.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>f(),2e3);else throw new Error(A.error||"Update failed")}catch(m){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+m.message,"error"),setTimeout(()=>{t.textContent="Update",t.disabled=!1,t.style.color="",t.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.dataset.id,r=t.dataset.name;if(confirm(`Rollback "${r}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(n)}`,{method:"POST"})).json();if(A.success)t.textContent="Rolled back!",setTimeout(()=>f(),2e3);else throw new Error(A.error||"Rollback failed")}catch(m){t.textContent="Failed",showNotification("Rollback error: "+m.message,"error"),setTimeout(()=>{t.textContent="Rollback",t.disabled=!1},3e3)}}})})}catch(p){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function L(){const p=window._pendingUpdates||[];if(!p.length)return;const e=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${p.length} containers? Each will restart.`))return;e.textContent="\u23F3 Updating...",e.disabled=!0;let i=0,l=0;for(const s of p)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?i++:l++}catch{l++}e.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(u)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;"> <select class="auto-schedule" data-id="${escapeHtml(m)}" 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=""${O?"":" selected"}>Disabled</option>
<option value="daily"${O==="daily"?" selected":""}>Daily</option> <option value="daily"${O==="daily"?" selected":""}>Daily</option>
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option> <option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option> <option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
</select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(u)}" 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>`,t+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(u)}"${_?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(u)}" data-name="${escapeHtml(i)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",M.innerHTML=t,M.querySelectorAll(".save-auto-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.dataset.id,u=n.closest("tr"),A=u.querySelector(".auto-schedule").value,O=u.querySelector(".auto-rollback").checked,_=u.querySelector(".auto-window").value.trim();n.textContent="Saving...",n.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!A,schedule:A||"weekly",autoRollback:O,maintenanceWindow:_||void 0})})).json();if(q.success)n.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){n.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{n.textContent="Save",n.disabled=!1},2e3)})})}catch(p){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const z=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),D=document.getElementById("dashcaddy-new-version"),y=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),$=document.getElementById("dashcaddy-check-btn"),U=document.getElementById("dashcaddy-rollback-btn"),m=document.getElementById("dashcaddy-status-bar"),v=document.getElementById("dashcaddy-history-container");let I=null;function b(p,e){m&&(m.style.display="block",m.style.background=e==="error"?"var(--bad-bg)":e==="success"?"var(--ok-bg)":"var(--bg)",m.style.color=e==="error"?"var(--bad-fg)":e==="success"?"var(--ok-fg)":"var(--fg)",m.textContent=p)}async function L(){try{const e=await(await fetch("/api/v1/system/version")).json();if(e.success){const s=e.commit&&e.commit!=="unknown"?e.commit:null;z.textContent="v"+e.version+(s?" ("+s.substring(0,7)+")":"")}}catch{z.textContent="Unable to fetch version"}}async function l(p){p||($.textContent="Checking...",$.disabled=!0);try{const s=await(await fetch("/api/v1/system/update-check")).json();if(I=s,s.success&&s.available&&s.remote){S.style.display="",T.style.display="",D.textContent="v"+s.remote.version,y.textContent=s.remote.changelog||"No changelog available.";const r=document.getElementById("updates-btn");if(r&&!r.querySelector(".update-dot")){const a=document.createElement("span");a.className="update-dot",a.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",r.style.position="relative",r.appendChild(a)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const a=document.createElement("span");a.className="update-dot",a.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(a)}}else S.style.display="none",T.style.display="none",await L(),p||b("You are running the latest version.","success");p||($.textContent="Check for Updates",$.disabled=!1)}catch(e){p||(b("Failed to check: "+e.message,"error"),$.textContent="Check for Updates",$.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,b("Downloading and applying update...","info");try{const e=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(e.success)return b("Update initiated: v"+(e.fromVersion||"?")+" \u2192 v"+(e.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(s=>s.remove()),!0;throw new Error(e.error||"Update failed")}catch(p){throw b("Update failed: "+p.message,"error"),B.textContent="Update Now",B.disabled=!1,p}}async function d(){try{const e=await(await fetch("/api/v1/system/update-history")).json(),s=e.success&&e.history?e.history:[];if(s.length===0){v.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let r='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';r+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated </select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(m)}" value="${escapeHtml(U)}" 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>`,t+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(m)}"${_?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(m)}" data-name="${escapeHtml(r)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",M.innerHTML=t,M.querySelectorAll(".save-auto-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,m=n.closest("tr"),A=m.querySelector(".auto-schedule").value,O=m.querySelector(".auto-rollback").checked,_=m.querySelector(".auto-window").value.trim();n.textContent="Saving...",n.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!A,schedule:A||"weekly",autoRollback:O,maintenanceWindow:_||void 0})})).json();if(q.success)n.textContent="\u2713 Saved";else throw new Error(q.error)}catch(U){n.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{n.textContent="Save",n.disabled=!1},2e3)})})}catch(p){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const z=document.getElementById("dashcaddy-current-version"),k=document.getElementById("dashcaddy-update-badge"),C=document.getElementById("dashcaddy-update-details"),D=document.getElementById("dashcaddy-new-version"),y=document.getElementById("dashcaddy-changelog"),T=document.getElementById("dashcaddy-apply-btn"),$=document.getElementById("dashcaddy-check-btn"),F=document.getElementById("dashcaddy-rollback-btn"),S=document.getElementById("dashcaddy-status-bar"),I=document.getElementById("dashcaddy-history-container");let B=null;function E(p,e){S&&(S.style.display="block",S.style.background=e==="error"?"var(--bad-bg)":e==="success"?"var(--ok-bg)":"var(--bg)",S.style.color=e==="error"?"var(--bad-fg)":e==="success"?"var(--ok-fg)":"var(--fg)",S.textContent=p)}async function u(){try{const e=await(await fetch("/api/v1/system/version")).json();if(e.success){const i=e.commit&&e.commit!=="unknown"?e.commit:null;z.textContent="v"+e.version+(i?" ("+i.substring(0,7)+")":"")}}catch{z.textContent="Unable to fetch version"}}async function o(p){p||($.textContent="Checking...",$.disabled=!0);try{const i=await(await fetch("/api/v1/system/update-check")).json();if(B=i,i.success&&i.available&&i.remote){k.style.display="",C.style.display="",D.textContent="v"+i.remote.version,y.textContent=i.remote.changelog||"No changelog available.";const l=document.getElementById("updates-btn");if(l&&!l.querySelector(".update-dot")){const a=document.createElement("span");a.className="update-dot",a.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",l.style.position="relative",l.appendChild(a)}const s=document.getElementById("updates-dashcaddy-tab");if(s&&!s.querySelector(".update-dot")){const a=document.createElement("span");a.className="update-dot",a.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",s.appendChild(a)}}else k.style.display="none",C.style.display="none",await u(),p||E("You are running the latest version.","success");p||($.textContent="Check for Updates",$.disabled=!1)}catch(e){p||(E("Failed to check: "+e.message,"error"),$.textContent="Check for Updates",$.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;T.textContent="Updating...",T.disabled=!0,E("Downloading and applying update...","info");try{const e=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(e.success)return E("Update initiated: v"+(e.fromVersion||"?")+" \u2192 v"+(e.toVersion||"?")+". The container will restart shortly.","success"),T.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(i=>i.remove()),!0;throw new Error(e.error||"Update failed")}catch(p){throw E("Update failed: "+p.message,"error"),T.textContent="Update Now",T.disabled=!1,p}}async function d(){try{const e=await(await fetch("/api/v1/system/update-history")).json(),i=e.success&&e.history?e.history:[];if(i.length===0){I.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+s.join(` `+i.join(`
`)+` `)+`
Enter version to rollback to:`);if(!r)return;if(!s.includes(r)){showNotification("Invalid version: "+r,"error");return}if(!confirm("Rollback DashCaddy to v"+r+"? The container will restart."))return;b("Rolling back to v"+r+"...","info");const a=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:r})})).json();if(a.success)b("Rollback to v"+r+" initiated. Container will restart.","success");else throw new Error(a.error||"Rollback failed")}catch(p){b("Rollback failed: "+p.message,"error")}}$?.addEventListener("click",()=>l(!1)),B?.addEventListener("click",()=>c().catch(()=>{})),U?.addEventListener("click",f),g?.addEventListener("click",E),x?.addEventListener("click",()=>{w?.classList.add("show"),h()}),wireModal(w,j),window.openUpdateModal=function(p){w?.classList.add("show"),h().then(()=>{if(!p)return;const e=N.querySelector(`[data-app-id="${p}"]`);e&&(e.scrollIntoView({behavior:"smooth",block:"center"}),e.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{e.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",()=>{L(),d(),I||l(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=l,setTimeout(()=>l(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal"> Enter version to rollback to:`);if(!l)return;if(!i.includes(l)){showNotification("Invalid version: "+l,"error");return}if(!confirm("Rollback DashCaddy to v"+l+"? The container will restart."))return;E("Rolling back to v"+l+"...","info");const a=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:l})})).json();if(a.success)E("Rollback to v"+l+" initiated. Container will restart.","success");else throw new Error(a.error||"Rollback failed")}catch(p){E("Rollback failed: "+p.message,"error")}}$?.addEventListener("click",()=>o(!1)),T?.addEventListener("click",()=>c().catch(()=>{})),F?.addEventListener("click",v),g?.addEventListener("click",w),h?.addEventListener("click",()=>{b?.classList.add("show"),f()}),wireModal(b,j),window.openUpdateModal=function(p){b?.classList.add("show"),f().then(()=>{if(!p)return;const e=N.querySelector(`[data-app-id="${p}"]`);e&&(e.scrollIntoView({behavior:"smooth",block:"center"}),e.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{e.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{u(),d(),B||o(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=o,setTimeout(()=>o(!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;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3> <h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p> <p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1711,7 +1711,7 @@ Enter version to rollback to:`);if(!r)return;if(!s.includes(r)){showNotification
<button id="dr-close">Close</button> <button id="dr-close">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("docker-resources-modal"),x=document.getElementById("docker-resources-btn"),j=document.getElementById("dr-close");function g(R){if(!R||R===0)return"0 B";const h=["B","KB","MB","GB","TB"],C=Math.floor(Math.log(Math.abs(R))/Math.log(1024));return(R/Math.pow(1024,C)).toFixed(1)+" "+h[C]}async function N(){const R=document.getElementById("dr-vol-list");try{const C=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(C.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let E='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';E+='<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 C){const k=H.name==="buildkit"||H.name.length===64;E+='<tr style="border-bottom: 1px solid var(--border);">',E+=`<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>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,E+='<td style="padding: 6px; text-align: right;">',k||(E+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),E+="</td></tr>"}E+="</table>",R.innerHTML=E,R.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`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(h){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-vol-name"),h=R.value.trim();if(!h){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:h}),R.value="",showNotification(`Volume "${h}" created`,"success"),N()}catch(C){showNotification("Create failed: "+C.message,"error")}});async function P(){const R=document.getElementById("dr-net-list");try{const C=(await getJSON("/api/v1/docker/networks")).networks||[];if(C.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let E='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';E+='<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 C){const k=["bridge","host","none"].includes(H.name);E+='<tr style="border-bottom: 1px solid var(--border);">',E+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,E+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,E+='<td style="padding: 6px; text-align: right;">',k||(E+=`<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>`),E+="</td></tr>"}E+="</table>",R.innerHTML=E,R.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){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-net-name"),h=document.getElementById("dr-net-driver"),C=R.value.trim();if(!C){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:C,driver:h.value}),R.value="",showNotification(`Network "${C}" created`,"success"),P()}catch(E){showNotification("Create failed: "+E.message,"error")}});async fuLine truncated </div>`);const b=document.getElementById("docker-resources-modal"),h=document.getElementById("docker-resources-btn"),j=document.getElementById("dr-close");function g(R){if(!R||R===0)return"0 B";const f=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(R))/Math.log(1024));return(R/Math.pow(1024,L)).toFixed(1)+" "+f[L]}async function N(){const R=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<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 L){const x=H.name==="buildkit"||H.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<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>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',x||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",R.innerHTML=w,R.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`),N()}catch(x){showNotification("Delete failed: "+x.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(f){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-vol-name"),f=R.value.trim();if(!f){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:f}),R.value="",showNotification(`Volume "${f}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function P(){const R=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<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 L){const x=["bridge","host","none"].includes(H.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',x||(w+=`<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>`),w+="</td></tr>"}w+="</table>",R.innerHTML=w,R.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(x){showNotification("Delete failed: "+x.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(f){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-net-name"),f=document.getElementById("dr-net-driver"),L=R.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:f.value}),R.value="",showNotification(`Network "${L}" created`,"success"),P()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;"> <div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3> <h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p> <p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1752,7 +1752,7 @@ Enter version to rollback to:`);if(!r)return;if(!s.includes(r)){showNotification
<div id="compose-progress-content"></div> <div id="compose-progress-content"></div>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("compose-import-modal"),x=document.getElementById("compose-import-btn"),j=document.getElementById("compose-cancel");wireModal(w,j);let g=null;function N(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"}x?.addEventListener("click",()=>{N("paste"),g=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 R=M.target.files[0];if(!R)return;const h=new FileReader;h.onload=()=>{document.getElementById("compose-yaml").value=h.result},h.readAsText(R)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const M=document.getElementById("compose-yaml").value.trim(),R=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"),C=h.textContent;h.textContent="Parsing...",h.disabled=!0;try{const E=await postJSON("/api/v1/apps/import-compose",{yaml:M,stackName:R});g=E,g.stackName=R,P(E),N("preview")}catch(E){showNotification("Parse failed: "+E.message,"error")}finally{h.textContent=C,h.disabled=!1}});function P(M){const R=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(C=>`<code>${escapeHtml(C)}</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(C=>`<code>${escapeHtml(C)}</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 C of M.services){const E=C.skip?"var(--bad-fg)":"var(--border)";if(h+=`<div style="padding: 10px 14px; border: 1px solid ${E}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,h+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(C.name)}`,C.skip&&(h+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(C.reason)}</span>`),h+="</div>",!C.skip&&(h+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(C.image)}</code></div>`,C.ports?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${C.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),C.volumes?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${C.volumes.length}</div>`),Object.keys(C.environment||{}).length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(C.environment).length}</div>`),C.envFileWarning&&(h+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(C.envFileWarning)}</div>`),C.resources?.cpus||C.resources?.memory)){const H=[];C.resources.cpus&&H.push(`CPU: ${C.resources.cpus}`),C.resources.memory&&H.push(`Mem: ${C.resources.memory}MB`),h+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}h+="</div>"}h+="</div>",R.innerHTML=h}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!g)return;const M=document.getElementById("compose-deploy-btn");M.textContent="Deploying...",M.disabled=!0,N("progress");const R=document.getElementById("compose-progress-content");R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const h=await postJSON("/api/v1/apps/deploy-compose",{services:g.services,networks:g.networks,stackName:g.stackName});let C=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(h.stackName)}" \u2014 Deployment Complete</div>`;C+='<div class="scroll-container" style="max-height: 350px;">';for(const E of h.results){const H=E.status==="deployed"||E.status==="created"?"\u2705":E.status==="exists"?"\u26A1":E.status==="skipped"?"\u23ED":"\u274C";C+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',C+=`${H} <strong>${escapeHtml(E.name)}</strong> (${E.type}) \u2014 ${escapeHtml(E.status)}`,E.error&&(C+=` <span style="color: var(--bad-fg);">${escapeHtml(E.error)}</span>`),E.subdomain&&(C+=` \u2192 <code>${escapeHtml(E.subdomain)}</code>`),E.reason&&(C+=` <span style="color: var(--muted);">(${escapeHtml(E.reason)})</span>`),C+="</div>"}C+="</div>",C+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated </div>`);const b=document.getElementById("compose-import-modal"),h=document.getElementById("compose-import-btn"),j=document.getElementById("compose-cancel");wireModal(b,j);let g=null;function N(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"}h?.addEventListener("click",()=>{N("paste"),g=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",M=>{const R=M.target.files[0];if(!R)return;const f=new FileReader;f.onload=()=>{document.getElementById("compose-yaml").value=f.result},f.readAsText(R)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const M=document.getElementById("compose-yaml").value.trim(),R=document.getElementById("compose-stack-name").value.trim()||"stack";if(!M){showNotification("Paste a docker-compose.yml","warning");return}const f=document.getElementById("compose-parse-btn"),L=f.textContent;f.textContent="Parsing...",f.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:M,stackName:R});g=w,g.stackName=R,P(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{f.textContent=L,f.disabled=!1}});function P(M){const R=document.getElementById("compose-preview-content");let f="";M.networks&&M.networks.length>0&&(f+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${M.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),M.volumes&&M.volumes.length>0&&(f+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${M.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),f+=`<div style="font-weight: 600; margin-bottom: 8px;">${M.services.length} service(s)</div>`,f+='<div class="scroll-container" style="max-height: 350px;">';for(const L of M.services){const w=L.skip?"var(--bad-fg)":"var(--border)";if(f+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,f+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(f+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),f+="</div>",!L.skip&&(f+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),L.volumes?.length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(f+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const H=[];L.resources.cpus&&H.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&H.push(`Mem: ${L.resources.memory}MB`),f+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}f+="</div>"}f+="</div>",R.innerHTML=f}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!g)return;const M=document.getElementById("compose-deploy-btn");M.textContent="Deploying...",M.disabled=!0,N("progress");const R=document.getElementById("compose-progress-content");R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const f=await postJSON("/api/v1/apps/deploy-compose",{services:g.services,networks:g.networks,stackName:g.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(f.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const w of f.results){const H=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${H} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(L+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),L+="</div>"}L+="</div>",L+='<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",()=>N("paste"))}finally{M.textContent="Deploy All",M.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal"> <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",()=>N("paste"))}finally{M.textContent="Deploy All",M.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;"> <div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3> <h3 id="exec-title">Terminal</h3>
@@ -1761,11 +1761,11 @@ Enter version to rollback to:`);if(!r)return;if(!s.includes(r)){showNotification
<button id="exec-close">Close</button> <button id="exec-close">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("exec-modal"),x=document.getElementById("exec-terminal"),j=document.getElementById("exec-close");let g=null,N=null,P=null;function M(){if(N){try{N.close()}catch{}N=null}if(g){try{g.dispose()}catch{}g=null}P=null,x.innerHTML=""}function R(h,C){if(M(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${C||h}`,w?.classList.add("show"),typeof Terminal>"u"){x.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}g=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,g.loadAddon(P)),g.open(x),P&&setTimeout(()=>P.fit(),50);const E=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${E}//${location.host}/ws/exec/${encodeURIComponent(h)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(g.writeln("\x1B[32mConnecting...\x1B[0m"),P){const k=P.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const z=JSON.parse(k.data);if(z.type==="connected"){g.writeln(`\x1B[32mConnected (${z.shell})\x1B[0m\r </div>`);const b=document.getElementById("exec-modal"),h=document.getElementById("exec-terminal"),j=document.getElementById("exec-close");let g=null,N=null,P=null;function M(){if(N){try{N.close()}catch{}N=null}if(g){try{g.dispose()}catch{}g=null}P=null,h.innerHTML=""}function R(f,L){if(M(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||f}`,b?.classList.add("show"),typeof Terminal>"u"){h.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}g=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,g.loadAddon(P)),g.open(h),P&&setTimeout(()=>P.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(f)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(g.writeln("\x1B[32mConnecting...\x1B[0m"),P){const x=P.proposeDimensions();x&&N.send(JSON.stringify({type:"resize",cols:x.cols,rows:x.rows}))}},N.onmessage=x=>{if(typeof x.data=="string"){try{const z=JSON.parse(x.data);if(z.type==="connected"){g.writeln(`\x1B[32mConnected (${z.shell})\x1B[0m\r
`);return}if(z.type==="error"){g.writeln(`\x1B[31mError: ${z.message}\x1B[0m`);return}if(z.type==="exit"){g.writeln(`\r `);return}if(z.type==="error"){g.writeln(`\x1B[31mError: ${z.message}\x1B[0m`);return}if(z.type==="exit"){g.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}g.write(k.data)}else g.write(new Uint8Array(k.data))},N.onclose=()=>{g&&g.writeln(`\r \x1B[33mSession ended.\x1B[0m`);return}}catch{}g.write(x.data)}else g.write(new Uint8Array(x.data))},N.onclose=()=>{g&&g.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{g&&g.writeln(`\r \x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{g&&g.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},g.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),g.onResize(({cols:k,rows:z})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:z}))});const H=()=>{P&&P.fit()};window.addEventListener("resize",H),w._resizeHandler=H}j?.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=R})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal"> \x1B[31mConnection error.\x1B[0m`)},g.onData(x=>{N&&N.readyState===WebSocket.OPEN&&N.send(x)}),g.onResize(({cols:x,rows:z})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:x,rows:z}))});const H=()=>{P&&P.fit()};window.addEventListener("resize",H),b._resizeHandler=H}j?.addEventListener("click",()=>{M(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",f=>{f.target===b&&(M(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=R})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;"> <div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>\u{1F4DC} Audit Log</h3> <h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1811,7 +1811,7 @@ Enter version to rollback to:`);if(!r)return;if(!s.includes(r)){showNotification
<button id="audit-cancel">Close</button> <button id="audit-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("audit-modal"),x=document.getElementById("audit-log-btn"),j=document.getElementById("audit-cancel"),g=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),M=document.getElementById("audit-outcome-filter"),R=document.getElementById("audit-since"),h=document.getElementById("audit-until"),C=document.getElementById("audit-log-container"),E=document.getElementById("audit-load-more");let H=0,k=null,z=0;const S=50;function T($){if(!$)return null;const U=new Date($);return isNaN(U.getTime())?null:U.toISOString()}async function D($){try{$?(k&&k.abort(),k=new AbortController):(k&&k.abort(),k=new AbortController,H=0,z++,C.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const U=z,m=new URLSearchParams;m.set("limit",String(S)),m.set("offset",String(H));const v=P.value,I=M.value,b=T(R.value),L=T(h.value);v&&m.set("action",v),I&&m.set("outcome",I),b&&m.set("since",b),L&&m.set("until",L);const l=await fetch("/api/v1/audit-logs?"+m.toString(),{signal:k.signal});if(!l.ok){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,E.style.display="none";return}const c=await l.json();if(!c.success){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.error||"unknown")}</div>`,E.style.display="none";return}if(!$&&U!==z)return;const d=Array.isArray(c.entries)?c.entries:[];if(d.length===0&&!$){const p=c.filters&&(c.filters.action||c.filters.outcome||c.filters.since||c.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";C.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(p)}</div>`,E.style.display="none";return}let f="";$||(f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',f+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',f+='<th style="padding: 6px; text-align: left;">When</th>',f+='<th style="padding: 6px; text-align: left;">Actor</th>',f+='<th style="padding: 6px; text-align: left;">IP</th>',f+='<th style="padding: 6px; text-align: left;">Action</th>',f+='<th style="padding: 6px; text-align: left;">Resource</th>',f+='<th style="padding: 6px; text-align: left;">Result</th>',f+="</tr>");for(const p of d){const e=p.outcome==="success",s=y(p);f+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',f+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(p.timestamp||"")}">${timeAgo(p.timestamp)}</td>`,f+=`<td style="padding: 6px; font-size: 0.78rem;">${s}</td>`,f+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(p.ip||"-")}</td>`,f+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(p.action||"-")}</td>`,f+=`<td style="padding: 6px;">${escapeHtml(p.resource||"-")}</td>`,f+=`<td style="padding: 6px;"><span style="color: ${e?"var(--ok-fg)":"var(--bad-fg)"};">${e?"\u2713":"\u2717"} ${escapeHtml(p.outcome||"")}</span></td>`,f+="</tr>",p.details&&Object.keys(p.details).length>0&&(f+=`<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(p.details,null,2))}</pre></td></tr>`)}if(!$)f+="</table>",C.innerHTML=f;else{const p=C.querySelector("table");p&&p.insertAdjacentHTML("beforeend",f)}H+=d.length,E.style.display=c.hasMore?"":"none",C.querySelectorAll(".audit-row").forEach(p=>{p.dataset.wired||(p.dataset.wired="true",p.addEventListener("click",()=>{const e=p.nextElementSibling;e&&e.classList.contains("audit-detail")&&(e.style.display=e.style.display==="none"?"":"none")}))})}catch(U){if(U&&U.name==="AbortError")return;C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(U.message)}</div>`}}function y($){const U=$.details||{},m=U.userEmail,v=U.userId,I=U.userRole,b=U.viaProvider;if(m){const L=I?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(I)}${b?"/"+escapeHtml(b):""}]</span>`:"";return`${escapeHtml(m)}${L}`}return v?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(v)}</span>`:$.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}x?.addEventListener("click",()=>{w?.classList.add("show"),D(!1)}),wireModal(w,j),g?.addEventListener("click",()=>D(!1)),P?.addEventListener("change",()=>D(!1)),M?.addEventListener("change",()=>D(!1));let B;[R,h].forEach($=>{$?.addEventListener("change",()=>{clearTimeout(B),B=setTimeout(()=>D(!1),250)})}),E?.addEventListener("click",()=>D(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const U=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated </div>`);const b=document.getElementById("audit-modal"),h=document.getElementById("audit-log-btn"),j=document.getElementById("audit-cancel"),g=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),M=document.getElementById("audit-outcome-filter"),R=document.getElementById("audit-since"),f=document.getElementById("audit-until"),L=document.getElementById("audit-log-container"),w=document.getElementById("audit-load-more");let H=0,x=null,z=0;const k=50;function C($){if(!$)return null;const F=new Date($);return isNaN(F.getTime())?null:F.toISOString()}async function D($){try{$?(x&&x.abort(),x=new AbortController):(x&&x.abort(),x=new AbortController,H=0,z++,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const F=z,S=new URLSearchParams;S.set("limit",String(k)),S.set("offset",String(H));const I=P.value,B=M.value,E=C(R.value),u=C(f.value);I&&S.set("action",I),B&&S.set("outcome",B),E&&S.set("since",E),u&&S.set("until",u);const o=await fetch("/api/v1/audit-logs?"+S.toString(),{signal:x.signal});if(!o.ok){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${o.status}</div>`,w.style.display="none";return}const c=await o.json();if(!c.success){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.error||"unknown")}</div>`,w.style.display="none";return}if(!$&&F!==z)return;const d=Array.isArray(c.entries)?c.entries:[];if(d.length===0&&!$){const p=c.filters&&(c.filters.action||c.filters.outcome||c.filters.since||c.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";L.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(p)}</div>`,w.style.display="none";return}let v="";$||(v='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',v+='<th style="padding: 6px; text-align: left;">When</th>',v+='<th style="padding: 6px; text-align: left;">Actor</th>',v+='<th style="padding: 6px; text-align: left;">IP</th>',v+='<th style="padding: 6px; text-align: left;">Action</th>',v+='<th style="padding: 6px; text-align: left;">Resource</th>',v+='<th style="padding: 6px; text-align: left;">Result</th>',v+="</tr>");for(const p of d){const e=p.outcome==="success",i=y(p);v+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',v+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(p.timestamp||"")}">${timeAgo(p.timestamp)}</td>`,v+=`<td style="padding: 6px; font-size: 0.78rem;">${i}</td>`,v+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(p.ip||"-")}</td>`,v+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(p.action||"-")}</td>`,v+=`<td style="padding: 6px;">${escapeHtml(p.resource||"-")}</td>`,v+=`<td style="padding: 6px;"><span style="color: ${e?"var(--ok-fg)":"var(--bad-fg)"};">${e?"\u2713":"\u2717"} ${escapeHtml(p.outcome||"")}</span></td>`,v+="</tr>",p.details&&Object.keys(p.details).length>0&&(v+=`<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(p.details,null,2))}</pre></td></tr>`)}if(!$)v+="</table>",L.innerHTML=v;else{const p=L.querySelector("table");p&&p.insertAdjacentHTML("beforeend",v)}H+=d.length,w.style.display=c.hasMore?"":"none",L.querySelectorAll(".audit-row").forEach(p=>{p.dataset.wired||(p.dataset.wired="true",p.addEventListener("click",()=>{const e=p.nextElementSibling;e&&e.classList.contains("audit-detail")&&(e.style.display=e.style.display==="none"?"":"none")}))})}catch(F){if(F&&F.name==="AbortError")return;L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(F.message)}</div>`}}function y($){const F=$.details||{},S=F.userEmail,I=F.userId,B=F.userRole,E=F.viaProvider;if(S){const u=B?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(B)}${E?"/"+escapeHtml(E):""}]</span>`:"";return`${escapeHtml(S)}${u}`}return I?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(I)}</span>`:$.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}h?.addEventListener("click",()=>{b?.classList.add("show"),D(!1)}),wireModal(b,j),g?.addEventListener("click",()=>D(!1)),P?.addEventListener("change",()=>D(!1)),M?.addEventListener("change",()=>D(!1));let T;[R,f].forEach($=>{$?.addEventListener("change",()=>{clearTimeout(T),T=setTimeout(()=>D(!1),250)})}),w?.addEventListener("click",()=>D(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const F=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;"> <div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
<h3>\u{1F6E1}\uFE0F Security Center</h3> <h3>\u{1F6E1}\uFE0F Security Center</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1890,28 +1890,28 @@ Enter version to rollback to:`);if(!r)return;if(!s.includes(r)){showNotification
<button id="sec-cancel">Close</button> <button id="sec-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("security-modal"),x=document.getElementById("security-center-btn"),j=document.getElementById("sec-cancel"),g=w.querySelectorAll(".sec-tab"),N=w.querySelectorAll(".sec-panel");let P=[],M=[],R=null;g.forEach(l=>{l.addEventListener("click",()=>{g.forEach(c=>c.classList.toggle("active",c===l)),N.forEach(c=>c.style.display=c.dataset.panel===l.dataset.tab?"":"none"),l.dataset.tab==="overview"&&H(),l.dataset.tab==="events"&&B(),l.dataset.tab==="hosts"&&v()})}),x&&x.addEventListener("click",()=>{w.classList.add("show"),H(),C()}),j.addEventListener("click",h),w.addEventListener("click",l=>{l.target===w&&h()});function h(){w.classList.remove("show"),E()}function C(){if(E(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{R=new EventSource("/api/v1/security/events/stream"),R.addEventListener("init",l=>{try{P=JSON.parse(l.data).events||[],$()}catch{}}),R.addEventListener("security",l=>{try{const c=JSON.parse(l.data);P.unshift(c),P.length>500&&(P.length=500);const d=w.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?$():d==="overview"&&H()}catch{}}),R.onerror=()=>{}}catch(l){console.warn("[security] SSE failed:",l.message)}}function E(){if(R){try{R.close()}catch{}R=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{w.classList.contains("show")&&C()});async function H(){try{const l=new Date(Date.now()-864e5).toISOString(),[c,d,f]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(l)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(l)}`)]),p=(await c.json()).data||{},e=(await d.json()).data?.hosts||[],s=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${s} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${p.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${p.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${p.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${e.length} hosts`,k("sec-top-actors",p.top_actors||[]),k("sec-top-targets",p.top_targets||[])}catch(l){console.warn("[security] refreshOverview failed:",l.message)}}function k(l,c){const d=document.getElementById(l);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(f=>`<tr><td style="padding:3px 0;word-break:break-all;">${b(String(f.key))}</td><td style="text-align:right;color:var(--muted);">${f.count}</td></tr>`).join("")+"</table>"}const z=document.getElementById("sec-filter-source"),S=document.getElementById("sec-filter-severity"),T=document.getElementById("sec-filter-host"),D=document.getElementById("sec-filter-actor"),y=document.getElementById("sec-refresh-btn");[z,S,T].forEach(l=>l.addEventListener("change",B)),D.addEventListener("input",L(B,250)),y.addEventListener("click",B);async function B(){try{const l=new URLSearchParams;l.set("limit","200"),z.value&&l.set("source_type",z.value),S.value&&l.set("severity",S.value),T.value&&l.set("source_host",T.value),D.value&&l.set("actor_prefix",D.value),P=(await(await fetch(`/api/v1/security/events?${l}`)).json()).data.events||[],$(),(!T.options.length||T.options.length===1)&&await m()}catch(l){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+b(l.message)+"</div>"}}function $(){const l=document.getElementById("sec-events-container");if(!P.length){l.innerHTML='<div class="panel-empty">No events</div>';return}l.innerHTML=P.slice(0,200).map(U).join("")}function U(l){const c=l.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",f=l.ts?new Date(l.ts).toLocaleTimeString():"",p=l.source_type||"",e=l.actor||"\u2014",s=l.target||"",r=l.action||"",o=l.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;"> </div>`);const b=document.getElementById("security-modal"),h=document.getElementById("security-center-btn"),j=document.getElementById("sec-cancel"),g=b.querySelectorAll(".sec-tab"),N=b.querySelectorAll(".sec-panel");let P=[],M=[],R=null;g.forEach(o=>{o.addEventListener("click",()=>{g.forEach(c=>c.classList.toggle("active",c===o)),N.forEach(c=>c.style.display=c.dataset.panel===o.dataset.tab?"":"none"),o.dataset.tab==="overview"&&H(),o.dataset.tab==="events"&&T(),o.dataset.tab==="hosts"&&I()})}),h&&h.addEventListener("click",()=>{b.classList.add("show"),H(),L()}),j.addEventListener("click",f),b.addEventListener("click",o=>{o.target===b&&f()});function f(){b.classList.remove("show"),w()}function L(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{R=new EventSource("/api/v1/security/events/stream"),R.addEventListener("init",o=>{try{P=JSON.parse(o.data).events||[],$()}catch{}}),R.addEventListener("security",o=>{try{const c=JSON.parse(o.data);P.unshift(c),P.length>500&&(P.length=500);const d=b.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?$():d==="overview"&&H()}catch{}}),R.onerror=()=>{}}catch(o){console.warn("[security] SSE failed:",o.message)}}function w(){if(R){try{R.close()}catch{}R=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{b.classList.contains("show")&&L()});async function H(){try{const o=new Date(Date.now()-864e5).toISOString(),[c,d,v]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(o)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(o)}`)]),p=(await c.json()).data||{},e=(await d.json()).data?.hosts||[],i=(await v.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${i} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${p.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${p.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${p.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${e.length} hosts`,x("sec-top-actors",p.top_actors||[]),x("sec-top-targets",p.top_targets||[])}catch(o){console.warn("[security] refreshOverview failed:",o.message)}}function x(o,c){const d=document.getElementById(o);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(v=>`<tr><td style="padding:3px 0;word-break:break-all;">${E(String(v.key))}</td><td style="text-align:right;color:var(--muted);">${v.count}</td></tr>`).join("")+"</table>"}const z=document.getElementById("sec-filter-source"),k=document.getElementById("sec-filter-severity"),C=document.getElementById("sec-filter-host"),D=document.getElementById("sec-filter-actor"),y=document.getElementById("sec-refresh-btn");[z,k,C].forEach(o=>o.addEventListener("change",T)),D.addEventListener("input",u(T,250)),y.addEventListener("click",T);async function T(){try{const o=new URLSearchParams;o.set("limit","200"),z.value&&o.set("source_type",z.value),k.value&&o.set("severity",k.value),C.value&&o.set("source_host",C.value),D.value&&o.set("actor_prefix",D.value),P=(await(await fetch(`/api/v1/security/events?${o}`)).json()).data.events||[],$(),(!C.options.length||C.options.length===1)&&await S()}catch(o){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+E(o.message)+"</div>"}}function $(){const o=document.getElementById("sec-events-container");if(!P.length){o.innerHTML='<div class="panel-empty">No events</div>';return}o.innerHTML=P.slice(0,200).map(F).join("")}function F(o){const c=o.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",v=o.ts?new Date(o.ts).toLocaleTimeString():"",p=o.source_type||"",e=o.actor||"\u2014",i=o.target||"",l=o.action||"",s=o.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;">${b(c)}</span> <span style="color:${d};font-weight:600;">${E(c)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${b(p)}</span> <span style="color:var(--muted);font-size:0.75rem;">${E(p)}</span>
<span title="${b(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b(e)}</span> <span title="${E(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${E(e)}</span>
<span title="${b(s)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b(r)} ${b(s)}</span> <span title="${E(i)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${E(l)} ${E(i)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${b(o)}</span> <span style="color:var(--muted);font-size:0.75rem;">${E(s)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${b(f)}</span> <span style="color:var(--muted);font-size:0.75rem;text-align:right;">${E(v)}</span>
</div>`}async function m(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=T.value;T.innerHTML='<option value="">All hosts</option>'+c.map(f=>`<option value="${b(f.id)}">${b(f.label||f.id)}</option>`).join(""),d&&(T.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",I),document.getElementById("sec-hosts-refresh").addEventListener("click",v);async function v(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];M=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(f=>{const p=f.enabled?f.last_seen_at?Date.now()-Date.parse(f.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>`}async function S(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=C.value;C.innerHTML='<option value="">All hosts</option>'+c.map(v=>`<option value="${E(v.id)}">${E(v.label||v.id)}</option>`).join(""),d&&(C.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",B),document.getElementById("sec-hosts-refresh").addEventListener("click",I);async function I(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];M=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(v=>{const p=v.enabled?v.last_seen_at?Date.now()-Date.parse(v.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> <div>
<strong>${b(f.label||f.id)}</strong> <strong>${E(v.label||v.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${b(f.type)}</span> <span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${E(v.type)}</span>
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;"> <div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
id: ${b(f.id)} \xB7 id: ${E(v.id)} \xB7
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7 registered ${new Date(v.registered_at).toLocaleDateString()} \xB7
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"} last seen ${v.last_seen_at?new Date(v.last_seen_at).toLocaleString():"never"}
</div> </div>
</div> </div>
<div> <div>
<span style="font-size:0.85rem;margin-right:12px;">${p}</span> <span style="font-size:0.85rem;margin-right:12px;">${p}</span>
${f.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${b(f.id)}" style="color:var(--bad-fg);">Remove</button>`} ${v.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${E(v.id)}" style="color:var(--bad-fg);">Remove</button>`}
</div> </div>
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(f=>{f.addEventListener("click",async()=>{confirm(`Remove host ${f.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(f.dataset.id)}`,{method:"DELETE"}),v())})})}catch(l){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+b(l.message)+"</div>"}}async function I(){const l=prompt("Host id (lowercase, no spaces):");if(!l)return;const c=prompt("Display label:",l)||l,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const f=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:l,label:c,type:d})}),p=await f.json();if(!f.ok){alert("Failed: "+(p?.error?.message||f.statusText));return}alert(`\u2705 Host registered! </div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(v=>{v.addEventListener("click",async()=>{confirm(`Remove host ${v.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(v.dataset.id)}`,{method:"DELETE"}),I())})})}catch(o){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+E(o.message)+"</div>"}}async function B(){const o=prompt("Host id (lowercase, no spaces):");if(!o)return;const c=prompt("Display label:",o)||o,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const v=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:o,label:c,type:d})}),p=await v.json();if(!v.ok){alert("Failed: "+(p?.error?.message||v.statusText));return}alert(`\u2705 Host registered!
id: ${p.data.host.id} id: ${p.data.host.id}
label: ${p.data.host.label} label: ${p.data.host.label}
@@ -1922,7 +1922,7 @@ type: ${p.data.host.type}
${p.data.api_key} ${p.data.api_key}
Send this key as: Authorization: Bearer <api_key> Send this key as: Authorization: Bearer <api_key>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(f){alert("Failed: "+f.message)}}function b(l){return String(l).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function L(l,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>l.apply(this,arguments),c)}}})(),window.__dc133_buildRepoOptions=function(w,x){var j=x||function(g){return String(g).replace(/[&<>"']/g,function(N){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[N]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(w||[]).map(function(g){return'<option value="'+j(g.url)+'" data-id="'+j(g.id)+'">'+j(g.full_name)+(g.description?" \u2014 "+j(g.description):"")+"</option>"}).join("")},window.__dc133_requestToken=function(w,x){if(w)return"";var j=String(x||"").trim();return j||void 0},(function(){injectModal("deploys-modal",`<div id="deploys-modal" class="weather-modal"> To endpoint: POST /api/v1/security/events/ingest or /events/batch`),I()}catch(v){alert("Failed: "+v.message)}}function E(o){return String(o).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function u(o,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>o.apply(this,arguments),c)}}})(),window.__dc133_buildRepoOptions=function(b,h){var j=h||function(g){return String(g).replace(/[&<>"']/g,function(N){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[N]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(b||[]).map(function(g){return'<option value="'+j(g.url)+'" data-id="'+j(g.id)+'">'+j(g.full_name)+(g.description?" \u2014 "+j(g.description):"")+"</option>"}).join("")},window.__dc133_requestToken=function(b,h){if(b)return"";var j=String(h||"").trim();return j||void 0},(function(){injectModal("deploys-modal",`<div id="deploys-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 860px; max-width: 1200px;"> <div class="weather-modal-content" style="min-width: 860px; max-width: 1200px;">
<h3>\u{1F69A} Deploys</h3> <h3>\u{1F69A} Deploys</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -2006,16 +2006,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(f)
<button class="btn" id="dep-close">Close</button> <button class="btn" id="dep-close">Close</button>
</div> </div>
</div> </div>
</div>`);const w=D=>document.getElementById(D);let x=null,j=!1;function g(D){return window.escapeHtml?window.escapeHtml(String(D)):String(D).replace(/[&<>"']/g,y=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[y])}async function N(D,y){const B=await fetch("/dashcaddy-api/api/v1/deploys"+D,Object.assign({credentials:"include"},y||{}));let $=null;try{$=await B.json()}catch{$={success:!1,error:"non-JSON response"}}return{status:B.status,body:$}}function P(D){const y=w("dep-output");y.style.display="block",y.textContent=D||"(no output)",y.scrollTop=y.scrollHeight}async function M(){const D=w("dep-services"),{body:y}=await N("/services");if(!y.success){D.innerHTML="<em>"+g(y.error||"unavailable")+"</em>";return}const B=y.services||[];if(!B.length){D.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}D.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th></tr></thead><tbody>'+B.map($=>"<tr><td><strong>"+g($.name)+"</strong></td><td>"+g($.last_action)+"</td><td>"+g($.last_time)+"</td><td>"+g($.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+g($.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+g($.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function R(){const D=w("dep-repo-select"),{body:y}=await N("/repos");if(!y.success){D.innerHTML="<option>"+g(y.error||"unavailable")+"</option>";return}const B=y.repos||[];D.innerHTML=B.length?B.map($=>'<option value="'+g($.dir)+'">'+g($.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function h(D){const y=w("dep-journal"),B=D?"?service="+encodeURIComponent(D):"",{body:$}=await N("/journal"+B),U=$.rows||[];if(!U.length){y.innerHTML="<em>No journal rows.</em>";return}y.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th></tr></thead><tbody>'+U.map(m=>"<tr><td>"+g(m.time)+"</td><td>"+g(m.service)+"</td><td>"+g(m.action)+"</td><td>"+g(m.epoch)+"</td><td>"+g(m.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function C(){const D=document.getElementById("dep-gh-gitea"),y=document.getElementById("dep-gh-gitea-host"),B=document.getElementById("dep-gh-gitea-token"),$=document.getElementById("dep-gh-anonymous"),U={};if(y&&y.value.trim()&&(U.gitea_url="https://"+y.value.trim().replace(/^https?:\/*/,"")),$&&$.checked)U.token="";else{const m=window.__dc133_requestToken(!1,B&&B.value);m!==void 0&&(U.token=m)}try{const{status:m,body:v}=await N("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(U)});if(!v.success||!(v.repos||[]).length){D.innerHTML='<option value="">'+g(v.error||"no repos found")+"</option>";return}D.innerHTML=window.__dc133_buildRepoOptions(v.repos,g)}catch{D.innerHTML='<option value="">unavailable</option>'}}async function E(){const D=document.getElementById("dep-gh-btn"),y=document.getElementById("dep-gh-url").value.trim(),B=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),$=document.getElementById("dep-gh-args").value.trim();if(!y||!B){P("Repo URL and name are required.");return}const U=$?$.split(/\s+/):[];H(!0,D,"Install"),P("Installing "+y+` </div>`);const b=D=>document.getElementById(D);let h=null,j=!1;function g(D){return window.escapeHtml?window.escapeHtml(String(D)):String(D).replace(/[&<>"']/g,y=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[y])}async function N(D,y){const T=await fetch("/dashcaddy-api/api/v1/deploys"+D,Object.assign({credentials:"include"},y||{}));let $=null;try{$=await T.json()}catch{$={success:!1,error:"non-JSON response"}}return{status:T.status,body:$}}function P(D){const y=b("dep-output");y.style.display="block",y.textContent=D||"(no output)",y.scrollTop=y.scrollHeight}async function M(){const D=b("dep-services"),{body:y}=await N("/services");if(!y.success){D.innerHTML="<em>"+g(y.error||"unavailable")+"</em>";return}const T=y.services||[];if(!T.length){D.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}D.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th></tr></thead><tbody>'+T.map($=>"<tr><td><strong>"+g($.name)+"</strong></td><td>"+g($.last_action)+"</td><td>"+g($.last_time)+"</td><td>"+g($.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+g($.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+g($.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function R(){const D=b("dep-repo-select"),{body:y}=await N("/repos");if(!y.success){D.innerHTML="<option>"+g(y.error||"unavailable")+"</option>";return}const T=y.repos||[];D.innerHTML=T.length?T.map($=>'<option value="'+g($.dir)+'">'+g($.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function f(D){const y=b("dep-journal"),T=D?"?service="+encodeURIComponent(D):"",{body:$}=await N("/journal"+T),F=$.rows||[];if(!F.length){y.innerHTML="<em>No journal rows.</em>";return}y.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th></tr></thead><tbody>'+F.map(S=>"<tr><td>"+g(S.time)+"</td><td>"+g(S.service)+"</td><td>"+g(S.action)+"</td><td>"+g(S.epoch)+"</td><td>"+g(S.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function L(){const D=document.getElementById("dep-gh-gitea"),y=document.getElementById("dep-gh-gitea-host"),T=document.getElementById("dep-gh-gitea-token"),$=document.getElementById("dep-gh-anonymous"),F={};if(y&&y.value.trim()&&(F.gitea_url="https://"+y.value.trim().replace(/^https?:\/*/,"")),$&&$.checked)F.token="";else{const S=window.__dc133_requestToken(!1,T&&T.value);S!==void 0&&(F.token=S)}try{const{status:S,body:I}=await N("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(F)});if(!I.success||!(I.repos||[]).length){D.innerHTML='<option value="">'+g(I.error||"no repos found")+"</option>";return}D.innerHTML=window.__dc133_buildRepoOptions(I.repos,g)}catch{D.innerHTML='<option value="">unavailable</option>'}}async function w(){const D=document.getElementById("dep-gh-btn"),y=document.getElementById("dep-gh-url").value.trim(),T=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),$=document.getElementById("dep-gh-args").value.trim();if(!y||!T){P("Repo URL and name are required.");return}const F=$?$.split(/\s+/):[];H(!0,D,"Install"),P("Installing "+y+`
Cloning, building, gating and DNS-ing... (~30-60s)`);try{const m=!!(document.getElementById("dep-gh-anonymous")||{}).checked,v=(document.getElementById("dep-gh-gitea-token")||{value:""}).value,I=window.__dc133_requestToken(m,v),{status:b,body:L}=await N("/install",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo_url:y,service:B,args:U,token:I})});if(!L.success){P((L.output?L.output+` Cloning, building, gating and DNS-ing... (~30-60s)`);try{const S=!!(document.getElementById("dep-gh-anonymous")||{}).checked,I=(document.getElementById("dep-gh-gitea-token")||{value:""}).value,B=window.__dc133_requestToken(S,I),{status:E,body:u}=await N("/install",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo_url:y,service:T,args:F,token:B})});if(!u.success){P((u.output?u.output+`
`:"")+"FAIL "+(L.error||"HTTP "+b)),H(!1,D,"Install");return}const l=L.service||{};if(!(window.APPS||[]).some(d=>d.id===l.id)){const d={id:l.id,name:l.name||l.id,url:l.url,logo:l.logo,tailscaleOnly:!0,isCustom:!0};try{await fetch("/dashcaddy-api/api/v1/services",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)}),window.APPS.push(d),typeof window.renderApps=="function"&&window.renderApps(),typeof window.renderGrid=="function"&&window.renderGrid()}catch{}}P("INSTALLED "+(l.name||l.id)+" in "+(l.deploy_seconds||"?")+`s `:"")+"FAIL "+(u.error||"HTTP "+E)),H(!1,D,"Install");return}const o=u.service||{};if(!(window.APPS||[]).some(d=>d.id===o.id)){const d={id:o.id,name:o.name||o.id,url:o.url,logo:o.logo,tailscaleOnly:!0,isCustom:!0};try{await fetch("/dashcaddy-api/api/v1/services",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)}),window.APPS.push(d),typeof window.renderApps=="function"&&window.renderApps(),typeof window.renderGrid=="function"&&window.renderGrid()}catch{}}P("INSTALLED "+(o.name||o.id)+" in "+(o.deploy_seconds||"?")+`s
Card: `+(l.url||"")+` Card: `+(o.url||"")+`
`+(L.output||"").slice(-800)),M()}catch(m){P("install error: "+m.message)}H(!1,D,"Install")}function H(D,y,B){y&&(y.disabled=D,B&&(y.textContent=D?"Working\u2026":B))}async function k(){const D=w("dep-deploy-btn"),y=w("dep-repo-select").value;if(y){H(!0,D,"Deploy"),P("Deploying "+y+` `+(u.output||"").slice(-800)),M()}catch(S){P("install error: "+S.message)}H(!1,D,"Install")}function H(D,y,T){y&&(y.disabled=D,T&&(y.textContent=D?"Working\u2026":T))}async function x(){const D=b("dep-deploy-btn"),y=b("dep-repo-select").value;if(y){H(!0,D,"Deploy"),P("Deploying "+y+`
This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir:y})});P((B.output||B.error||"")+(B.success?` This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir:y})});P((T.output||T.error||"")+(T.success?`
\u2705 SUCCESS`:` \u2705 SUCCESS`:`
\u274C FAILED`)),M()}catch(B){P("deploy error: "+B.message)}H(!1,D,"Deploy")}}async function z(D,y){if(confirm("Roll back "+D+" to the previous release?")){H(!0,y,"Rollback"),P("Rolling back "+D+"\u2026");try{const{body:B}=await N("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:D})});P((B.output||B.error||"")+(B.success?` \u274C FAILED`)),M()}catch(T){P("deploy error: "+T.message)}H(!1,D,"Deploy")}}async function z(D,y){if(confirm("Roll back "+D+" to the previous release?")){H(!0,y,"Rollback"),P("Rolling back "+D+"\u2026");try{const{body:T}=await N("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:D})});P((T.output||T.error||"")+(T.success?`
\u2705 ROLLED BACK`:` \u2705 ROLLED BACK`:`
\u274C FAILED`)),M()}catch(B){P("rollback error: "+B.message)}H(!1,y,"Rollback")}}async function S(D,y){H(!0,y,"Status");try{const{body:B}=await N("/status?service="+encodeURIComponent(D));P(B.output||B.error||"(no output)")}catch(B){P("status error: "+B.message)}H(!1,y,"Status")}function T(){x=document.getElementById("deploys-btn"),x&&(x.addEventListener("click",async()=>{w("deploys-modal").classList.add("open"),j||(j=!0,M(),R(),h(""),C())}),w("dep-close").addEventListener("click",()=>{w("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(D=>{D.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(y=>y.classList.remove("active")),D.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(y=>{y.style.display=y.dataset.panel===D.dataset.tab?"block":"none"})})}),w("dep-deploy-btn").addEventListener("click",k),w("dep-gh-btn").addEventListener("click",E),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(D=>{const y=document.getElementById(D);y&&y.addEventListener("change",()=>C())}),document.getElementById("dep-gh-gitea").addEventListener("change",D=>{const y=D.target.value;if(y){document.getElementById("dep-gh-url").value=y;const B=D.target.selectedOptions[0],$=B&&B.dataset?B.dataset.id:"",U=document.getElementById("dep-gh-service");$&&!U.value&&(U.value=$)}}),w("dep-journal-btn").addEventListener("click",()=>h(w("dep-journal-name").value.trim())),w("dep-status-btn").addEventListener("click",()=>{const D=w("dep-status-name").value.trim();D&&S(D)}),w("dep-services").addEventListener("click",D=>{const y=D.target.closest(".dep-rollback");if(y)return z(y.dataset.service,y);const B=D.target.closest(".dep-status");if(B)return S(B.dataset.service,B)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",T):T()})(),(function(){const w=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3> \u274C FAILED`)),M()}catch(T){P("rollback error: "+T.message)}H(!1,y,"Rollback")}}async function k(D,y){H(!0,y,"Status");try{const{body:T}=await N("/status?service="+encodeURIComponent(D));P(T.output||T.error||"(no output)")}catch(T){P("status error: "+T.message)}H(!1,y,"Status")}function C(){h=document.getElementById("deploys-btn"),h&&(h.addEventListener("click",async()=>{b("deploys-modal").classList.add("open"),j||(j=!0,M(),R(),f(""),L())}),b("dep-close").addEventListener("click",()=>{b("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(D=>{D.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(y=>y.classList.remove("active")),D.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(y=>{y.style.display=y.dataset.panel===D.dataset.tab?"block":"none"})})}),b("dep-deploy-btn").addEventListener("click",x),b("dep-gh-btn").addEventListener("click",w),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(D=>{const y=document.getElementById(D);y&&y.addEventListener("change",()=>L())}),document.getElementById("dep-gh-gitea").addEventListener("change",D=>{const y=D.target.value;if(y){document.getElementById("dep-gh-url").value=y;const T=D.target.selectedOptions[0],$=T&&T.dataset?T.dataset.id:"",F=document.getElementById("dep-gh-service");$&&!F.value&&(F.value=$)}}),b("dep-journal-btn").addEventListener("click",()=>f(b("dep-journal-name").value.trim())),b("dep-status-btn").addEventListener("click",()=>{const D=b("dep-status-name").value.trim();D&&k(D)}),b("dep-services").addEventListener("click",D=>{const y=D.target.closest(".dep-rollback");if(y)return z(y.dataset.service,y);const T=D.target.closest(".dep-status");if(T)return k(T.dataset.service,T)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",C):C()})(),(function(){const b=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> <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"> <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> <div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -2026,23 +2026,23 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
<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> <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> </div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const x="weather-location",j="weather-zip",g="weather-geo",N="weather-unit";!safeGet(x)&&safeGet(j)&&safeSet(x,safeGet(j));function P(){return safeGet(N)||"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 R={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"},C=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function E(y){return C[Math.round(y/22.5)%16]}async function H(y){const B=safeGet(g);if(B)try{const I=JSON.parse(B);if(I.query===y)return I}catch{}const $=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(y)}&count=1&language=en&format=json`);if(!$.ok)throw new Error("Geocoding failed");const U=await $.json();if(!U.results||!U.results.length)throw new Error("Location not found");const m=U.results[0],v={query:y,lat:m.latitude,lon:m.longitude,city:m.name,state:m.admin1||"",country:m.country||"",countryCode:m.country_code||""};return safeSet(g,JSON.stringify(v)),v}function k(y){return y.countryCode==="US"&&y.state?`${y.city}, ${y.state}`:y.country?`${y.city}, ${y.country}`:y.city}async function z(y){try{const B=await H(y),$=P(),U=$==="metric"?"celsius":"fahrenheit",m=$==="metric"?"kmh":"mph",v=`https://api.open-meteo.com/v1/forecast?latitude=${B.lat}&longitude=${B.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${U}&wind_speed_unit=${m}`,I=await fetch(v);if(!I.ok)throw new Error("Weather fetch failed");const L=(await I.json()).current,l=L.weather_code;return{temp:Math.round(L.temperature_2m),condition:R[l]||"Unknown",icon:h[l]||"\u{1F324}\uFE0F",locationStr:k(B),windSpeed:Math.round(L.wind_speed_10m),windDir:E(L.wind_direction_10m),unit:$}}catch(B){return console.warn("Weather fetch failed:",B),null}}async function S(){const y=M();if(!y.icon||!y.temp||!y.condition||!y.location||!y.wind){console.warn("Weather widget elements not found");return}const B=safeGet(x);if(!B){y.location.textContent="Set Location",y.temp.textContent="--\xB0",y.condition.textContent="Click \u2699\uFE0F to configure",y.wind.textContent="--",y.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const $=await z(B);if($){const U=$.unit==="metric"?"\xB0C":"\xB0F",m=$.unit==="metric"?"km/h":"mph";y.location.textContent=$.locationStr,y.temp.textContent=`${$.temp}${U}`,y.condition.textContent=$.condition,y.wind.textContent=`Wind: ${$.windSpeed} ${m} ${$.windDir}`,y.icon.innerHTML=`<span class="weather-emoji">${escapeHtml($.icon)}</span>`}}catch($){w.logError("[Weather] Update Error",$,{function:"updateWeather"}),y.location.textContent="Weather Error",y.temp.textContent="Error",y.condition.textContent="Failed to load",y.wind.textContent="--"}}const T=document.getElementById("weather-modal"),D=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{D.value=safeGet(x)||"";const y=P(),B=T.querySelector(`input[name="weather-unit-radio"][value="${y}"]`);B&&(B.checked=!0),T.classList.add("show"),D.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const y=D.value.trim();if(y){safeGet(x)!==y&&safeSet(g,""),safeSet(x,y);const $=T.querySelector('input[name="weather-unit-radio"]:checked'),U=$?$.value:"imperial",m=P();safeSet(N,U),m!==U&&safeSet(g,""),T.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",y=>{y.keyLine truncated <div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const h="weather-location",j="weather-zip",g="weather-geo",N="weather-unit";!safeGet(h)&&safeGet(j)&&safeSet(h,safeGet(j));function P(){return safeGet(N)||"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 R={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"},f={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"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(y){return L[Math.round(y/22.5)%16]}async function H(y){const T=safeGet(g);if(T)try{const B=JSON.parse(T);if(B.query===y)return B}catch{}const $=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(y)}&count=1&language=en&format=json`);if(!$.ok)throw new Error("Geocoding failed");const F=await $.json();if(!F.results||!F.results.length)throw new Error("Location not found");const S=F.results[0],I={query:y,lat:S.latitude,lon:S.longitude,city:S.name,state:S.admin1||"",country:S.country||"",countryCode:S.country_code||""};return safeSet(g,JSON.stringify(I)),I}function x(y){return y.countryCode==="US"&&y.state?`${y.city}, ${y.state}`:y.country?`${y.city}, ${y.country}`:y.city}async function z(y){try{const T=await H(y),$=P(),F=$==="metric"?"celsius":"fahrenheit",S=$==="metric"?"kmh":"mph",I=`https://api.open-meteo.com/v1/forecast?latitude=${T.lat}&longitude=${T.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${F}&wind_speed_unit=${S}`,B=await fetch(I);if(!B.ok)throw new Error("Weather fetch failed");const u=(await B.json()).current,o=u.weather_code;return{temp:Math.round(u.temperature_2m),condition:R[o]||"Unknown",icon:f[o]||"\u{1F324}\uFE0F",locationStr:x(T),windSpeed:Math.round(u.wind_speed_10m),windDir:w(u.wind_direction_10m),unit:$}}catch(T){return console.warn("Weather fetch failed:",T),null}}async function k(){const y=M();if(!y.icon||!y.temp||!y.condition||!y.location||!y.wind){console.warn("Weather widget elements not found");return}const T=safeGet(h);if(!T){y.location.textContent="Set Location",y.temp.textContent="--\xB0",y.condition.textContent="Click \u2699\uFE0F to configure",y.wind.textContent="--",y.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const $=await z(T);if($){const F=$.unit==="metric"?"\xB0C":"\xB0F",S=$.unit==="metric"?"km/h":"mph";y.location.textContent=$.locationStr,y.temp.textContent=`${$.temp}${F}`,y.condition.textContent=$.condition,y.wind.textContent=`Wind: ${$.windSpeed} ${S} ${$.windDir}`,y.icon.innerHTML=`<span class="weather-emoji">${escapeHtml($.icon)}</span>`}}catch($){b.logError("[Weather] Update Error",$,{function:"updateWeather"}),y.location.textContent="Weather Error",y.temp.textContent="Error",y.condition.textContent="Failed to load",y.wind.textContent="--"}}const C=document.getElementById("weather-modal"),D=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{D.value=safeGet(h)||"";const y=P(),T=C.querySelector(`input[name="weather-unit-radio"][value="${y}"]`);T&&(T.checked=!0),C.classList.add("show"),D.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const y=D.value.trim();if(y){safeGet(h)!==y&&safeSet(g,""),safeSet(h,y);const $=C.querySelector('input[name="weather-unit-radio"]:checked'),F=$?$.value:"imperial",S=P();safeSet(N,F),S!==F&&safeSet(g,""),C.classList.remove("show"),k()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(C),document.addEventListener("keydown",y=>{y.keyLine truncated
<circle cx="${n}" cy="${i}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/> <circle cx="${n}" cy="${r}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F} ${U}
${_} ${_}
<line x1="${n}" y1="${i}" x2="${n+28*Math.cos(O*Math.PI/180)}" y2="${i+28*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/> <line x1="${n}" y1="${r}" x2="${n+28*Math.cos(O*Math.PI/180)}" y2="${r+28*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${n}" y1="${i}" x2="${n+38*Math.cos(A*Math.PI/180)}" y2="${i+38*Math.sin(A*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/> <line x1="${n}" y1="${r}" x2="${n+38*Math.cos(A*Math.PI/180)}" y2="${r+38*Math.sin(A*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${n}" y1="${i}" x2="${n+42*Math.cos(u*Math.PI/180)}" y2="${i+42*Math.sin(u*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/> <line x1="${n}" y1="${r}" x2="${n+42*Math.cos(m*Math.PI/180)}" y2="${r+42*Math.sin(m*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${n}" cy="${i}" r="3" fill="var(--fg)"/> <circle cx="${n}" cy="${r}" r="3" fill="var(--fg)"/>
</svg>`,J=e.getHours()>=12?"PM":"AM";x.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${e.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${z(e)}</span></div></div>`,C="analog"}function v(){const e=new Date,s=e.getHours()%12||12,r=e.getMinutes(),o=e.getSeconds(),a="clock-widget"+(P!=="default"?" "+P:"");switch(w.className!==a&&(w.className=a),P){case"lcd":y(e);break;case"lcd-blue":y(e);break;case"lcd-amber":y(e);break;case"lcd-retro":y(e);break;case"lcd-taxi":y(e);break;case"flip":B(e);break;case"binary":U(e);break;case"analog":m(e,!1);break;case"roman":m(e,!0);break;default:D(e)}r===0&&o===0&&s!==M&&(M=s,k(s)),r!==0&&(M=-1)}function I(){clearTimeout(H);const e=document.hidden?6e4:1e3,s=e-Date.now()%e+25;H=setTimeout(()=>{v(),I()},s)}document.addEventListener("visibilitychange",()=>{h="",S(),v(),I()}),v(),I();const b=[{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 L='<div class="clock-style-grid">';b.forEach(e=>{L+=`<label class="clock-style-option"> </svg>`,J=e.getHours()>=12?"PM":"AM";h.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${e.getHours()%12||12}:${String(s).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${z(e)}</span></div></div>`,L="analog"}function I(){const e=new Date,i=e.getHours()%12||12,l=e.getMinutes(),s=e.getSeconds(),a="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==a&&(b.className=a),P){case"lcd":y(e);break;case"lcd-blue":y(e);break;case"lcd-amber":y(e);break;case"lcd-retro":y(e);break;case"lcd-taxi":y(e);break;case"flip":T(e);break;case"binary":F(e);break;case"analog":S(e,!1);break;case"roman":S(e,!0);break;default:D(e)}l===0&&s===0&&i!==M&&(M=i,x(i)),l!==0&&(M=-1)}function B(){clearTimeout(H);const e=document.hidden?6e4:1e3,i=e-Date.now()%e+25;H=setTimeout(()=>{I(),B()},i)}document.addEventListener("visibilitychange",()=>{f="",k(),I(),B()}),I(),B();const E=[{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 u='<div class="clock-style-grid">';E.forEach(e=>{u+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${e.id}"> <input type="radio" name="clock-style-radio" value="${e.id}">
<span class="clock-style-card"><span class="clock-style-icon">${e.icon}</span><span class="clock-style-label">${e.label}</span></span> <span class="clock-style-card"><span class="clock-style-icon">${e.icon}</span><span class="clock-style-label">${e.label}</span></span>
</label>`}),L+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal"> </label>`}),u+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="max-width: 420px;"> <div class="weather-modal-content" style="max-width: 420px;">
<h3>Clock Settings</h3> <h3>Clock Settings</h3>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label> <label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${L} ${u}
</div> </div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);"> <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);"> <label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -2066,7 +2066,7 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
<button id="clock-settings-save">Save</button> <button id="clock-settings-save">Save</button>
</div> </div>
</div> </div>
</div>`);const l=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),f=document.getElementById("clock-volume-section");function p(){const e=safeGet("clock-style")||"default",s=l.querySelector(`input[value="${e}"]`);s&&(s.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",f.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{f.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{p(),l.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const e=parseInt(d.value,10)/100,s=new Audio("/assets/sounds/church-bell.mp3");s.volume=e,s.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const e=l.querySelector('input[name="clock-style-radio"]:checked'),s=e?e.value:"default";safeSet("clock-style",s),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),P=s,h="",S(),v(),I(),l.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{l.classList.remove("show")}),wireModal(l),l?.querySelectorAll('input[name="clock-style-radio"]').forEach(e=>{e.addEventListener("change",()=>{P=e.value,h="",S(),v()})})})(),(function(){function w(g){const N=new Date(g.start),P=isNaN(N.getTime())?`invalid:${g.start==null?"null":String(g.start)}`:N.toISOString();return g.uid?`${g.uid}@${P}`:`${P}|${g.title||""}`}function x(){const g=new Map;return{size:()=>g.size,merge(N,P){if(P)for(const[M,R]of g){const h=new Date(R.start).getTime();h>=P.start.getTime()&&h<=P.end.getTime()&&g.delete(M)}for(const M of N&&Array.isArray(N.events)?N.events:[])g.set(w(M),M);return{events:[...g.values()].sort((M,R)=>new Date(M.start)-new Date(R.start))}}}}const j={eventKey:w,createCache:x};typeof module<"u"&&module.exports&&(module.exports=j),typeof window<"u"&&(window.CalendarCache=j)})(),(function(){const w=new ErrorHandler;injectModal("calendar-modal",`<div id="calendar-modal" class="weather-modal"> </div>`);const o=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),v=document.getElementById("clock-volume-section");function p(){const e=safeGet("clock-style")||"default",i=o.querySelector(`input[value="${e}"]`);i&&(i.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",v.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{v.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{p(),o.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const e=parseInt(d.value,10)/100,i=new Audio("/assets/sounds/church-bell.mp3");i.volume=e,i.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const e=o.querySelector('input[name="clock-style-radio"]:checked'),i=e?e.value:"default";safeSet("clock-style",i),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),P=i,f="",k(),I(),B(),o.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{o.classList.remove("show")}),wireModal(o),o?.querySelectorAll('input[name="clock-style-radio"]').forEach(e=>{e.addEventListener("change",()=>{P=e.value,f="",k(),I()})})})(),(function(){function b(g){const N=new Date(g.start),P=isNaN(N.getTime())?`invalid:${g.start==null?"null":String(g.start)}`:N.toISOString();return g.uid?`${g.uid}@${P}`:`${P}|${g.title||""}`}function h(){const g=new Map;return{size:()=>g.size,merge(N,P){if(P)for(const[M,R]of g){const f=new Date(R.start).getTime();f>=P.start.getTime()&&f<=P.end.getTime()&&g.delete(M)}for(const M of N&&Array.isArray(N.events)?N.events:[])g.set(b(M),M);return{events:[...g.values()].sort((M,R)=>new Date(M.start)-new Date(R.start))}}}}const j={eventKey:b,createCache:h};typeof module<"u"&&module.exports&&(module.exports=j),typeof window<"u"&&(window.CalendarCache=j)})(),(function(){const b=new ErrorHandler;injectModal("calendar-modal",`<div id="calendar-modal" class="weather-modal">
<div class="weather-modal-content cal-modal-content" role="dialog" aria-modal="true" aria-labelledby="calendar-modal-title"> <div class="weather-modal-content cal-modal-content" role="dialog" aria-modal="true" aria-labelledby="calendar-modal-title">
<h3 id="calendar-modal-title">\u{1F4C5} Calendar</h3> <h3 id="calendar-modal-title">\u{1F4C5} Calendar</h3>
<div class="cal-monthbar"> <div class="cal-monthbar">
@@ -2118,18 +2118,18 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
</details> </details>
</div> </div>
<div class="weather-modal-buttons"><button id="calendar-close">Close</button></div> <div class="weather-modal-buttons"><button id="calendar-close">Close</button></div>
</div></div>`);function x(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}const j=()=>({icon:document.querySelector(".calendar-widget-icon"),text:document.querySelector(".calendar-widget-text")});function g(a,t){const n=new Date(a),i=new Date;i.setHours(0,0,0,0);const u=new Date(n);u.setHours(0,0,0,0);const A=Math.round((u-i)/864e5),O=A===0?"Today":A===1?"Tomorrow":n.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),_=t?"all-day":n.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${O} ${_}`}function N(a,t){return t?"all-day":new Date(a).toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"})}function P(a){return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}-${String(a.getDate()).padStart(2,"0")}`}const M=typeof window<"u"&&window.CalendarCache?window.CalendarCache.createCache():null;function R(a,t){return M?M.merge(a,t):a}let h=[];function C(a){const t=a||new Date,n=new Date(t.getFullYear(),t.getMonth()-1,1,0,0,0,0),i=new Date(t.getFullYear(),t.getMonth()+2,0,23,59,59,999);return{start:n,end:i}}function E(a){return h.some(t=>t.start.getTime()<=a.start.getTime()&&t.end.getTime()>=a.end.getTime())}function H(a){const t=h.filter(i=>i.end.getTime()>=a.start.getTime()&&i.start.getTime()<=a.end.getTime()),n=h.filter(i=>!t.includes(i));t.length&&(a={start:new Date(Math.min(a.start.getTime(),...t.map(i=>i.start.getTime()))),end:new Date(Math.max(a.end.getTime(),...t.map(i=>i.end.getTime())))}),h=n.concat([a]).sort((i,u)=>i.start-u.start)}let k=null,z={events:[]};async function S(a){const t=C(k);if(!a&&E(t))return z;const n=await secureFetch(`/api/v1/calendar/events?start=${t.start.toISOString()}&end=${t.end.toISOString()}`,{cache:"no-store"});if(n.status===404)return{notConfigured:!0};if(!n.ok)throw new Error(`Calendar API HTTP ${n.status}`);const i=await n.json();return z=R(i,t),H(t),z}function T(a){const t=j();if(!t.icon||!t.text)return;const n=B(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.icon.textContent="\u{1F4C5}",t.text.innerHTML='<span class="cal-chip-muted">No upcoming appointments</span>';return}const i=n[0];t.icon.textContent=i.allDay?"\u{1F5D3}\uFE0F":"\u23F0",t.text.innerHTML=`<span class="cal-chip-title">${x(i.title||"Untitled")}</span><span class="cal-chip-when">\xB7 ${x(g(i.start,i.allDay))}</span>`}function D(a){const t={};for(const n of a&&Array.isArray(a.events)?a.events:[]){const i=P(new Date(n.start));(t[i]=t[i]||[]).push(n)}return t}function y(a){const t=document.getElementById("cal-grid"),n=document.getElementById("cal-month-label");if(!t||!n)return;k||(k=new Date,k.setDate(1));const i=k.getFullYear(),u=k.getMonth();n.textContent=k.toLocaleDateString(void 0,{month:"long",year:"numeric"});const A=D(a),O=P(new Date),_=new Date(i,u,1).getDay(),F=new Date(i,u+1,0).getDate();let q=["S","M","T","W","T","F","S"].map(J=>`<div class="cal-dow">${J}</div>`).join("");for(let J=0;J<_;J++)q+='<div class="cal-day cal-pad"></div>';for(let J=1;J<=F;J++){const G=`${i}-${String(u+1).padStart(2,"0")}-${String(J).padStart(2,"0")}`,Z=A[G]||[],ee=["cal-day"];G===O&&ee.push("cal-today"),Z.length&&ee.push("cal-has-events");const ne=Z.slice(0,3).map(K=>`<span class="cal-dot" title="${x(N(K.start,K.allDay))}"></span>`).join(""),oe=Z.map(K=>`${N(K.start,K.allDay)} \u2014 ${K.title||"Untitled"}`).join(` </div></div>`);function h(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}const j=()=>({icon:document.querySelector(".calendar-widget-icon"),text:document.querySelector(".calendar-widget-text")});function g(a,t){const n=new Date(a),r=new Date;r.setHours(0,0,0,0);const m=new Date(n);m.setHours(0,0,0,0);const A=Math.round((m-r)/864e5),O=A===0?"Today":A===1?"Tomorrow":n.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),_=t?"all-day":n.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${O} ${_}`}function N(a,t){return t?"all-day":new Date(a).toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"})}function P(a){return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}-${String(a.getDate()).padStart(2,"0")}`}const M=typeof window<"u"&&window.CalendarCache?window.CalendarCache.createCache():null;function R(a,t){return M?M.merge(a,t):a}let f=[];function L(a){const t=a||new Date,n=new Date(t.getFullYear(),t.getMonth()-1,1,0,0,0,0),r=new Date(t.getFullYear(),t.getMonth()+2,0,23,59,59,999);return{start:n,end:r}}function w(a){return f.some(t=>t.start.getTime()<=a.start.getTime()&&t.end.getTime()>=a.end.getTime())}function H(a){const t=f.filter(r=>r.end.getTime()>=a.start.getTime()&&r.start.getTime()<=a.end.getTime()),n=f.filter(r=>!t.includes(r));t.length&&(a={start:new Date(Math.min(a.start.getTime(),...t.map(r=>r.start.getTime()))),end:new Date(Math.max(a.end.getTime(),...t.map(r=>r.end.getTime())))}),f=n.concat([a]).sort((r,m)=>r.start-m.start)}let x=null,z={events:[]};async function k(a){const t=L(x);if(!a&&w(t))return z;const n=await secureFetch(`/api/v1/calendar/events?start=${t.start.toISOString()}&end=${t.end.toISOString()}`,{cache:"no-store"});if(n.status===404)return{notConfigured:!0};if(!n.ok)throw new Error(`Calendar API HTTP ${n.status}`);const r=await n.json();return z=R(r,t),H(t),z}function C(a){const t=j();if(!t.icon||!t.text)return;const n=T(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.icon.textContent="\u{1F4C5}",t.text.innerHTML='<span class="cal-chip-muted">No upcoming appointments</span>';return}const r=n[0];t.icon.textContent=r.allDay?"\u{1F5D3}\uFE0F":"\u23F0",t.text.innerHTML=`<span class="cal-chip-title">${h(r.title||"Untitled")}</span><span class="cal-chip-when">\xB7 ${h(g(r.start,r.allDay))}</span>`}function D(a){const t={};for(const n of a&&Array.isArray(a.events)?a.events:[]){const r=P(new Date(n.start));(t[r]=t[r]||[]).push(n)}return t}function y(a){const t=document.getElementById("cal-grid"),n=document.getElementById("cal-month-label");if(!t||!n)return;x||(x=new Date,x.setDate(1));const r=x.getFullYear(),m=x.getMonth();n.textContent=x.toLocaleDateString(void 0,{month:"long",year:"numeric"});const A=D(a),O=P(new Date),_=new Date(r,m,1).getDay(),U=new Date(r,m+1,0).getDate();let q=["S","M","T","W","T","F","S"].map(J=>`<div class="cal-dow">${J}</div>`).join("");for(let J=0;J<_;J++)q+='<div class="cal-day cal-pad"></div>';for(let J=1;J<=U;J++){const G=`${r}-${String(m+1).padStart(2,"0")}-${String(J).padStart(2,"0")}`,Z=A[G]||[],ee=["cal-day"];G===O&&ee.push("cal-today"),Z.length&&ee.push("cal-has-events");const ne=Z.slice(0,3).map(K=>`<span class="cal-dot" title="${h(N(K.start,K.allDay))}"></span>`).join(""),oe=Z.map(K=>`${N(K.start,K.allDay)} \u2014 ${K.title||"Untitled"}`).join(`
`);q+=`<div class="${ee.join(" ")}" title="${x(oe||G)}"><span class="cal-daynum">${J}</span>${ne?`<span class="cal-dots">${ne}</span>`:""}</div>`}t.innerHTML=q}function B(a){const t=Date.now();return(Array.isArray(a)?a:[]).filter(n=>(n.end?new Date(n.end).getTime():new Date(n.start).getTime())>=t).sort((n,i)=>new Date(n.start)-new Date(i.start))}function $(a){const t=document.getElementById("calendar-upcoming-list");if(!t)return;const n=B(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.innerHTML='<div class="cal-empty">No upcoming appointments.</div>';return}t.innerHTML=n.slice(0,12).map(i=>`<div class="cal-row"> `);q+=`<div class="${ee.join(" ")}" title="${h(oe||G)}"><span class="cal-daynum">${J}</span>${ne?`<span class="cal-dots">${ne}</span>`:""}</div>`}t.innerHTML=q}function T(a){const t=Date.now();return(Array.isArray(a)?a:[]).filter(n=>(n.end?new Date(n.end).getTime():new Date(n.start).getTime())>=t).sort((n,r)=>new Date(n.start)-new Date(r.start))}function $(a){const t=document.getElementById("calendar-upcoming-list");if(!t)return;const n=T(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.innerHTML='<div class="cal-empty">No upcoming appointments.</div>';return}t.innerHTML=n.slice(0,12).map(r=>`<div class="cal-row">
<span class="cal-when">${x(g(i.start,i.allDay))}</span> <span class="cal-when">${h(g(r.start,r.allDay))}</span>
<span class="cal-evtitle">${x(i.title||"Untitled")}</span> <span class="cal-evtitle">${h(r.title||"Untitled")}</span>
${i.location?`<span class="cal-loc">${x(i.location)}</span>`:""} ${r.location?`<span class="cal-loc">${h(r.location)}</span>`:""}
</div>`).join("")}async function U(){const a=document.getElementById("calendar-sources-list");if(!a)return;let t=[];try{const n=await secureFetch("/api/v1/calendar/sources",{cache:"no-store"});if(n.ok){const i=await n.json();t=i&&Array.isArray(i.sources)?i.sources:Array.isArray(i)?i:[]}}catch{}if(!t.length){a.innerHTML='<div class="cal-empty">No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.</div>';return}a.innerHTML=t.map(n=>` </div>`).join("")}async function F(){const a=document.getElementById("calendar-sources-list");if(!a)return;let t=[];try{const n=await secureFetch("/api/v1/calendar/sources",{cache:"no-store"});if(n.ok){const r=await n.json();t=r&&Array.isArray(r.sources)?r.sources:Array.isArray(r)?r:[]}}catch{}if(!t.length){a.innerHTML='<div class="cal-empty">No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.</div>';return}a.innerHTML=t.map(n=>`
<div class="cal-row cal-src-row"> <div class="cal-row cal-src-row">
<span class="cal-evtitle">${x(n.name||n.id)} <span class="cal-evtitle">${h(n.name||n.id)}
<span class="cal-loc">\xB7 ${x(n.type)}${n.autoProvisioned?" \xB7 auto-wired":""}</span></span> <span class="cal-loc">\xB7 ${h(n.type)}${n.autoProvisioned?" \xB7 auto-wired":""}</span></span>
<button class="cal-src-toggle cal-btn" data-id="${x(n.id)}" data-enabled="${n.enabled!==!1?"1":"0"}">${n.enabled!==!1?"\u2713 on":"off"}</button> <button class="cal-src-toggle cal-btn" data-id="${h(n.id)}" data-enabled="${n.enabled!==!1?"1":"0"}">${n.enabled!==!1?"\u2713 on":"off"}</button>
<button class="cal-src-del cal-btn cal-btn-danger" data-id="${x(n.id)}">remove</button> <button class="cal-src-del cal-btn cal-btn-danger" data-id="${h(n.id)}">remove</button>
</div>`).join(""),a.querySelectorAll(".cal-src-toggle").forEach(n=>n.addEventListener("click",async()=>{const i=n.dataset.id,u=n.dataset.enabled!=="1";n.disabled=!0;try{const A=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(i)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:u})});if(!A.ok)throw new Error(`HTTP ${A.status}`);U(),v(!0)}catch(A){showNotification(`Calendar source update failed: ${A.message}`,"error")}finally{n.disabled=!1}})),a.querySelectorAll(".cal-src-del").forEach(n=>n.addEventListener("click",async()=>{const i=n.dataset.id;n.disabled=!0;try{const u=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(i)}`,{method:"DELETE"});if(!u.ok)throw new Error(`HTTP ${u.status}`);U(),v(!0)}catch(u){showNotification(`Calendar source removal failed: ${u.message}`,"error")}finally{n.disabled=!1}}))}let m=null;async function v(a){if(v._inFlight){a&&(v._pendingForce=!0);return}v._inFlight=!0;try{let t=await S(!!a);if(a&&k){const n=k;k=null;const i=await S(!0);k=n;const u=await S(!0);T(i),y(u),$(i),m=i,v._inFlight=!1;return}if(k){const n=k;k=null;const i=await S(!1);k=n;const u=await S(!1);T(i),y(u),$(i),m=i,v._inFlight=!1;return}m=t,T(t),document.getElementById("calendar-modal").classList.contains("show")&&(y(t),$(t))}catch(t){w.logError("calendar-widget",t);const n=j();n.text&&(n.text.innerHTML='<span class="cal-chip-muted">Calendar offline</span>')}finally{v._inFlight=!1,v._pendingForce&&(v._pendingForce=!1,v(!0))}}let I=null;function b(){I=document.activeElement,document.getElementById("calendar-modal").classList.add("show"),y(m||{events:[]}),$(m||{events:[]}),U(),v(),document.getElementById("calendar-close").focus()}function L(){document.getElementById("calendar-modal").classList.remove("show"),I&&typeof I.focus=="function"&&I.focus()}function l(){return{type:document.getElementById("cal-add-type").value,name:document.getElementById("cal-add-name").value.trim(),url:document.getElementById("cal-add-url").value.trim(),user:document.getElementById("cal-add-user").value.trim(),pass:document.getElementById("cal-add-pass").value}}function c(a){document.getElementById("cal-add-status").textContent=a}document.getElementById("cal-add-test").addEventListener("click",async()=>{const a=l();if(!a.url){c("Enter a URL first.");return}c("Testing\u2026");try{const n=await(await secureFetch("/api/v1/calendar/discover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:a.url,user:a.user,pass:a.pass})})).json(),i=n&&n.probe;i&&i.ok?c(`\u2713 Reachable (${i.server||"server"})${i.looksLikeCalendar?" \u2014 looks like a calendar":""}`):c(`\u2717 ${i&&i.error?i.error:"Not reachable"}`)}catch(t){c(`\u2717 ${t.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const a=l();if(!a.name||!a.url){c("Name and URL are required.");return}try{const t=await secureFetch("/api/v1/calendar/sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:a.name,type:a.type,url:a.url,user:a.user,pass:a.pass})});if(!t.ok){const n=await t.json().catch(()=>({}));c(`\u2717 ${n.error||n.message||`HTTP ${t.status}`}`);return}c("Saved."),document.getElementById("cal-add-details").open=!1,["cal-add-name","cal-add-url","cal-add-user","cal-add-pass"].forEach(n=>{document.getElementById(n).value=""}),U(),v(!0)}catch(t){c(`\u2717 ${t.message}`)}});const d=document.getElementById("calendar-widget");d&&(d.addEventListener("click",b),d.addEventListener("keydown",a=>{(a.key==="Enter"||a.key===" "||a.key==="Spacebar")&&(a.preventDefault(),b())}));const f=document.getElementById("calendar-settings");f&&f.addEventListener("click",a=>{a.stopPropagation(),b()});const p=document.getElementById("calendar-modal");p&&(document.getElementById("calendar-close").addEventListener("click",L),p.addEventListener("click",a=>{a.target===p&&L()}),document.addEventListener("keydown",a=>{a.key==="Escape"&&p.classList.contains("show")&&L()}));async function e(a){k||(k=new Date,k.setDate(1)),k=new Date(k.getFullYear(),k.getMonth()+a,1);const t=await S(!1).catch(()=>m);y(t||{events:[]})}const s=document.getElementById("cal-prev");s&&s.addEventListener("click",()=>{e(-1)});const r=document.getElementById("cal-next");r&&r.addEventListener("click",()=>{e(1)}),window.refreshCalendarWidget=v;const o=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(()=>{document.hidden||v(!0)},o),document.addEventListener("visibilitychange",()=>{document.hidden||v(!0)}),setTimeout(v,400)})(),(function(){const w=new ErrorHandler;injectModal("ai-connect-modal",`<div id="ai-connect-modal" class="weather-modal"> </div>`).join(""),a.querySelectorAll(".cal-src-toggle").forEach(n=>n.addEventListener("click",async()=>{const r=n.dataset.id,m=n.dataset.enabled!=="1";n.disabled=!0;try{const A=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(r)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:m})});if(!A.ok)throw new Error(`HTTP ${A.status}`);F(),I(!0)}catch(A){showNotification(`Calendar source update failed: ${A.message}`,"error")}finally{n.disabled=!1}})),a.querySelectorAll(".cal-src-del").forEach(n=>n.addEventListener("click",async()=>{const r=n.dataset.id;n.disabled=!0;try{const m=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(r)}`,{method:"DELETE"});if(!m.ok)throw new Error(`HTTP ${m.status}`);F(),I(!0)}catch(m){showNotification(`Calendar source removal failed: ${m.message}`,"error")}finally{n.disabled=!1}}))}let S=null;async function I(a){if(I._inFlight){a&&(I._pendingForce=!0);return}I._inFlight=!0;try{let t=await k(!!a);if(a&&x){const n=x;x=null;const r=await k(!0);x=n;const m=await k(!0);C(r),y(m),$(r),S=r,I._inFlight=!1;return}if(x){const n=x;x=null;const r=await k(!1);x=n;const m=await k(!1);C(r),y(m),$(r),S=r,I._inFlight=!1;return}S=t,C(t),document.getElementById("calendar-modal").classList.contains("show")&&(y(t),$(t))}catch(t){b.logError("calendar-widget",t);const n=j();n.text&&(n.text.innerHTML='<span class="cal-chip-muted">Calendar offline</span>')}finally{I._inFlight=!1,I._pendingForce&&(I._pendingForce=!1,I(!0))}}let B=null;function E(){B=document.activeElement,document.getElementById("calendar-modal").classList.add("show"),y(S||{events:[]}),$(S||{events:[]}),F(),I(),document.getElementById("calendar-close").focus()}function u(){document.getElementById("calendar-modal").classList.remove("show"),B&&typeof B.focus=="function"&&B.focus()}function o(){return{type:document.getElementById("cal-add-type").value,name:document.getElementById("cal-add-name").value.trim(),url:document.getElementById("cal-add-url").value.trim(),user:document.getElementById("cal-add-user").value.trim(),pass:document.getElementById("cal-add-pass").value}}function c(a){document.getElementById("cal-add-status").textContent=a}document.getElementById("cal-add-test").addEventListener("click",async()=>{const a=o();if(!a.url){c("Enter a URL first.");return}c("Testing\u2026");try{const n=await(await secureFetch("/api/v1/calendar/discover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:a.url,user:a.user,pass:a.pass})})).json(),r=n&&n.probe;r&&r.ok?c(`\u2713 Reachable (${r.server||"server"})${r.looksLikeCalendar?" \u2014 looks like a calendar":""}`):c(`\u2717 ${r&&r.error?r.error:"Not reachable"}`)}catch(t){c(`\u2717 ${t.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const a=o();if(!a.name||!a.url){c("Name and URL are required.");return}try{const t=await secureFetch("/api/v1/calendar/sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:a.name,type:a.type,url:a.url,user:a.user,pass:a.pass})});if(!t.ok){const n=await t.json().catch(()=>({}));c(`\u2717 ${n.error||n.message||`HTTP ${t.status}`}`);return}c("Saved."),document.getElementById("cal-add-details").open=!1,["cal-add-name","cal-add-url","cal-add-user","cal-add-pass"].forEach(n=>{document.getElementById(n).value=""}),F(),I(!0)}catch(t){c(`\u2717 ${t.message}`)}});const d=document.getElementById("calendar-widget");d&&(d.addEventListener("click",E),d.addEventListener("keydown",a=>{(a.key==="Enter"||a.key===" "||a.key==="Spacebar")&&(a.preventDefault(),E())}));const v=document.getElementById("calendar-settings");v&&v.addEventListener("click",a=>{a.stopPropagation(),E()});const p=document.getElementById("calendar-modal");p&&(document.getElementById("calendar-close").addEventListener("click",u),p.addEventListener("click",a=>{a.target===p&&u()}),document.addEventListener("keydown",a=>{a.key==="Escape"&&p.classList.contains("show")&&u()}));async function e(a){x||(x=new Date,x.setDate(1)),x=new Date(x.getFullYear(),x.getMonth()+a,1);const t=await k(!1).catch(()=>S);y(t||{events:[]})}const i=document.getElementById("cal-prev");i&&i.addEventListener("click",()=>{e(-1)});const l=document.getElementById("cal-next");l&&l.addEventListener("click",()=>{e(1)}),window.refreshCalendarWidget=I;const s=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(()=>{document.hidden||I(!0)},s),document.addEventListener("visibilitychange",()=>{document.hidden||I(!0)}),setTimeout(I,400)})(),(function(){const b=new ErrorHandler;injectModal("ai-connect-modal",`<div id="ai-connect-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width:560px;max-width:720px;"> <div class="weather-modal-content" style="min-width:560px;max-width:720px;">
<h3>\u{1F916} Connect your AI assistant</h3> <h3>\u{1F916} Connect your AI assistant</h3>
<div id="aiconn-intro" style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;"> <div id="aiconn-intro" style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">
@@ -2183,12 +2183,12 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
<span id="aiconn-status" style="flex:1;font-size:0.78rem;color:var(--muted);align-self:center;"></span> <span id="aiconn-status" style="flex:1;font-size:0.78rem;color:var(--muted);align-self:center;"></span>
<button id="aiconn-close">Close</button> <button id="aiconn-close">Close</button>
</div> </div>
</div></div>`);const x=h=>document.getElementById(h);let j=null;function g(h){x("aiconn-status").textContent=h}async function N(){try{const h=await secureFetch("/api/v1/mcp/info");if(!h.ok)throw new Error(`HTTP ${h.status}`);const C=await h.json();j=C,x("aiconn-endpoint").style.display="block",x("aiconn-endpoint-url").textContent=C.endpoint,x("aiconn-tools").textContent=`${C.toolCount} tools \xB7 MCP ${C.protocolVersion} \xB7 DashCaddy v${C.serverVersion}`}catch(h){w.log(h,"ai-connect-info"),g("MCP endpoint unavailable on this build.")}}async function P(){const h=x("aiconn-connections");try{const E=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),H=E&&Array.isArray(E.connections)?E.connections:[];if(!H.length){h.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}h.innerHTML=H.map(k=>` </div></div>`);const h=f=>document.getElementById(f);let j=null;function g(f){h("aiconn-status").textContent=f}async function N(){try{const f=await secureFetch("/api/v1/mcp/info");if(!f.ok)throw new Error(`HTTP ${f.status}`);const L=await f.json();j=L,h("aiconn-endpoint").style.display="block",h("aiconn-endpoint-url").textContent=L.endpoint,h("aiconn-tools").textContent=`${L.toolCount} tools \xB7 MCP ${L.protocolVersion} \xB7 DashCaddy v${L.serverVersion}`}catch(f){b.log(f,"ai-connect-info"),g("MCP endpoint unavailable on this build.")}}async function P(){const f=h("aiconn-connections");try{const w=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),H=w&&Array.isArray(w.connections)?w.connections:[];if(!H.length){f.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}f.innerHTML=H.map(x=>`
<div style="display:flex;gap:8px;align-items:center;font-size:0.8rem;"> <div style="display:flex;gap:8px;align-items:center;font-size:0.8rem;">
<span style="flex:1;">${escapeHtml(k.name)} <span style="flex:1;">${escapeHtml(x.name)}
<span style="color:var(--muted);">\xB7 ${(k.scopes||[]).join(",")} \xB7 since ${escapeHtml((k.createdAt||"").slice(0,10))}${k.lastUsed?" \xB7 last used "+escapeHtml(String(k.lastUsed).slice(0,10)):""}</span></span> <span style="color:var(--muted);">\xB7 ${(x.scopes||[]).join(",")} \xB7 since ${escapeHtml((x.createdAt||"").slice(0,10))}${x.lastUsed?" \xB7 last used "+escapeHtml(String(x.lastUsed).slice(0,10)):""}</span></span>
<button class="aiconn-revoke" data-id="${escapeHtml(k.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button> <button class="aiconn-revoke" data-id="${escapeHtml(x.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button>
</div>`).join(""),h.querySelectorAll(".aiconn-revoke").forEach(k=>k.addEventListener("click",async()=>{k.disabled=!0;try{const z=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(k.dataset.id)}`,{method:"DELETE"});if(z.ok)g("Connection revoked."),P();else{const S=await z.json().catch(()=>({}));g(S.error||"Revoke failed."),k.disabled=!1}}catch(z){g("Revoke failed: "+z.message),k.disabled=!1}}))}catch{h.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function M(h){g(`Creating ${h} connection\u2026`);try{const C=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:h})}),E=await C.json().catch(()=>({}));if(!C.ok||!E.key){g(E.error||`Failed (HTTP ${C.status})`);return}x("aiconn-key").textContent=E.key;const H=E.httpConfig?JSON.stringify(E.httpConfig,null,2):"";x("aiconn-snippet").textContent=H||"(see note below)",x("aiconn-client-note").textContent=E.note||"",x("aiconn-config-title").textContent=h==="chatgpt"?"Access key (ChatGPT note below):":"Your access key (shown once):",x("aiconn-step-pick").style.display="none",x("aiconn-step-config").style.display="block",g(""),P()}catch(C){w.log(C,"ai-connect-create"),g("Failed: "+C.message)}}function R(h,C){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(h).then(()=>g(C)).catch(()=>g("Copy failed \u2014 select manually.")):g("Clipboard unavailable \u2014 select manually.")}x("ai-connect-btn").addEventListener("click",()=>{x("ai-connect-modal").style.display="flex",x("aiconn-step-pick").style.display="block",x("aiconn-step-config").style.display="none",g(""),N(),P()}),x("aiconn-close").addEventListener("click",()=>{x("ai-connect-modal").style.display="none"}),x("aiconn-back").addEventListener("click",()=>{x("aiconn-step-config").style.display="none",x("aiconn-step-pick").style.display="block"}),document.querySelectorAll(".aiconn-client-btn").forEach(h=>h.addEventListener("click",()=>M(h.dataset.client))),x("aiconn-copy-key").addEventListener("click",()=>R(x("aiconn-key").textContent,"Key copied \u2014 paste it into your client.")),x("aiconn-copy-snippet").addEventListener("click",()=>R(x("aiconn-snippet").textContent,"Config copied.")),x("aiconn-copy-endpoint").addEventListener("click",()=>R(j?j.endpoint:x("aiconn-endpoint-url").textContent,"Endpoint copied."))})(),(function(){async function w(){try{const R=await(await fetch("/api/v1/health-checks/status")).json();if(!R.success||!R.status)return;for(const[h,C]of Object.entries(R.status)){const E=document.getElementById("uptime-"+h),H=document.getElementById("uptime-bar-"+h);if(!E)continue;const k=C.uptime?.["24h"];if(k!=null){const z=k.toFixed(1);E.textContent=`${z}% uptime`,E.className="uptime-chip",k>=99.9?E.classList.add("excellent"):k>=99?E.classList.add("good"):k>=95?E.classList.add("degraded"):E.classList.add("poor"),H&&(H.style.width=z+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let x;try{x=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{x=new Set}let j=[];async function g(M){try{const h=await(await fetch("/api/v1/updates/available")).json();if(!h.success)return;document.querySelectorAll(".update-available-badge").forEach(E=>E.classList.remove("visible"));const C=h.updates||[];if(j=C,M&&C.length>0){const E=window._lastKnownUpdateCount||0;E>0&&C.length>E&&showNotification(`${C.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=C.length}if(!C.length)return;for(const E of C){const H=window.APPS||[];for(const k of H)if(k.containerId===E.containerId||k.id===E.containerName||k.name===E.containerName){if(x.has(k.id))break;const z=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);z&&(z.classList.add("visible"),z.title="Update available \u2014 click to open Update Management.",z.style.cursor="pointer",z.onclick=T=>{T.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 N(){setTimeout(()=>{w(),g()},5e3),setInterval(()=>{w(),g(!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)}}),N()})(),(function(){var w=null,x=null,j={},g={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["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"]Line truncated </div>`).join(""),f.querySelectorAll(".aiconn-revoke").forEach(x=>x.addEventListener("click",async()=>{x.disabled=!0;try{const z=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(x.dataset.id)}`,{method:"DELETE"});if(z.ok)g("Connection revoked."),P();else{const k=await z.json().catch(()=>({}));g(k.error||"Revoke failed."),x.disabled=!1}}catch(z){g("Revoke failed: "+z.message),x.disabled=!1}}))}catch{f.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function M(f){g(`Creating ${f} connection\u2026`);try{const L=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:f})}),w=await L.json().catch(()=>({}));if(!L.ok||!w.key){g(w.error||`Failed (HTTP ${L.status})`);return}h("aiconn-key").textContent=w.key;const H=w.httpConfig?JSON.stringify(w.httpConfig,null,2):"";h("aiconn-snippet").textContent=H||"(see note below)",h("aiconn-client-note").textContent=w.note||"",h("aiconn-config-title").textContent=f==="chatgpt"?"Access key (ChatGPT note below):":"Your access key (shown once):",h("aiconn-step-pick").style.display="none",h("aiconn-step-config").style.display="block",g(""),P()}catch(L){b.log(L,"ai-connect-create"),g("Failed: "+L.message)}}function R(f,L){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(f).then(()=>g(L)).catch(()=>g("Copy failed \u2014 select manually.")):g("Clipboard unavailable \u2014 select manually.")}h("ai-connect-btn").addEventListener("click",()=>{h("ai-connect-modal").style.display="flex",h("aiconn-step-pick").style.display="block",h("aiconn-step-config").style.display="none",g(""),N(),P()}),h("aiconn-close").addEventListener("click",()=>{h("ai-connect-modal").style.display="none"}),h("aiconn-back").addEventListener("click",()=>{h("aiconn-step-config").style.display="none",h("aiconn-step-pick").style.display="block"}),document.querySelectorAll(".aiconn-client-btn").forEach(f=>f.addEventListener("click",()=>M(f.dataset.client))),h("aiconn-copy-key").addEventListener("click",()=>R(h("aiconn-key").textContent,"Key copied \u2014 paste it into your client.")),h("aiconn-copy-snippet").addEventListener("click",()=>R(h("aiconn-snippet").textContent,"Config copied.")),h("aiconn-copy-endpoint").addEventListener("click",()=>R(j?j.endpoint:h("aiconn-endpoint-url").textContent,"Endpoint copied."))})(),(function(){async function b(){try{const R=await(await fetch("/api/v1/health-checks/status")).json();if(!R.success||!R.status)return;for(const[f,L]of Object.entries(R.status)){const w=document.getElementById("uptime-"+f),H=document.getElementById("uptime-bar-"+f);if(!w)continue;const x=L.uptime?.["24h"];if(x!=null){const z=x.toFixed(1);w.textContent=`${z}% uptime`,w.className="uptime-chip",x>=99.9?w.classList.add("excellent"):x>=99?w.classList.add("good"):x>=95?w.classList.add("degraded"):w.classList.add("poor"),H&&(H.style.width=z+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let h;try{h=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{h=new Set}let j=[];async function g(M){try{const f=await(await fetch("/api/v1/updates/available")).json();if(!f.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const L=f.updates||[];if(j=L,M&&L.length>0){const w=window._lastKnownUpdateCount||0;w>0&&L.length>w&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const w of L){const H=window.APPS||[];for(const x of H)if(x.containerId===w.containerId||x.id===w.containerName||x.name===w.containerName){if(h.has(x.id))break;const z=document.getElementById("update-badge-"+x.id),k=document.getElementById("update-btn-"+x.id);z&&(z.classList.add("visible"),z.title="Update available \u2014 click to open Update Management.",z.style.cursor="pointer",z.onclick=C=>{C.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(x.id)}),k&&(k.style.background="#f97316",k.style.borderColor="#f97316",k.style.boxShadow="0 0 6px #f9731688",k.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{b(),g()},5e3),setInterval(()=>{b(),g(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(b,1e3)}catch(M){console.warn("[Card Badges] Error in refreshAll hook:",M.message)}}),N()})(),(function(){var b=null,h=null,j={},g={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["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"]Line truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3> <h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;"> <p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -2225,16 +2225,16 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
<button id="license-activate" class="btn-accent">Activate</button> <button id="license-activate" class="btn-accent">Activate</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("license-modal"),x=document.getElementById("license-code-input"),j=document.getElementById("license-activate"),g=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),P=document.getElementById("license-success"),M=document.getElementById("license-badge-icon"),R=document.getElementById("license-badge-text"),h=document.getElementById("license-badge"),C=document.getElementById("license-details"),E=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let k=null;function z(){N.style.display="none",P.style.display="none"}function S(I){z(),N.textContent=I,N.style.display="block"}function T(I){z(),P.textContent=I,P.style.display="block"}function D(I){if(k=I,I.active){h.style.background="rgba(46,204,113,0.15)",h.style.color="var(--ok-fg)",M.textContent="\u2605",R.textContent="Premium Active";const l=I.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(I.expiresAt).toLocaleDateString()}</strong> (${I.daysRemaining} days remaining)</div>`;C.innerHTML=` </div>`);const b=document.getElementById("license-modal"),h=document.getElementById("license-code-input"),j=document.getElementById("license-activate"),g=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),P=document.getElementById("license-success"),M=document.getElementById("license-badge-icon"),R=document.getElementById("license-badge-text"),f=document.getElementById("license-badge"),L=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let x=null;function z(){N.style.display="none",P.style.display="none"}function k(B){z(),N.textContent=B,N.style.display="block"}function C(B){z(),P.textContent=B,P.style.display="block"}function D(B){if(x=B,B.active){f.style.background="rgba(46,204,113,0.15)",f.style.color="var(--ok-fg)",M.textContent="\u2605",R.textContent="Premium Active";const o=B.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(B.expiresAt).toLocaleDateString()}</strong> (${B.daysRemaining} days remaining)</div>`;L.innerHTML=`
<div>Code: <code style="font-family: monospace;">${I.code||"***"}</code></div> <div>Code: <code style="font-family: monospace;">${B.code||"***"}</code></div>
${l} ${o}
`,H.style.display="none",j.style.display="none",g.style.display=""}else h.style.background="rgba(149,165,166,0.15)",h.style.color="var(--muted)",M.textContent="\u2606",R.textContent=I.expired?"License Expired":"Free Tier",C.innerHTML=I.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="",j.style.display="",g.style.display="none";const b=I.premiumFeatures||{},L=new Set(I.features||[]);E.innerHTML=Object.entries(b).map(([l,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);"> `,H.style.display="none",j.style.display="none",g.style.display=""}else f.style.background="rgba(149,165,166,0.15)",f.style.color="var(--muted)",M.textContent="\u2606",R.textContent=B.expired?"License Expired":"Free Tier",L.innerHTML=B.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="",j.style.display="",g.style.display="none";const E=B.premiumFeatures||{},u=new Set(B.features||[]);w.innerHTML=Object.entries(E).map(([o,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;">${L.has(l)?"\u2705":"\u{1F512}"}</span> <span style="font-size: 1.1rem;">${u.has(o)?"\u2705":"\u{1F512}"}</span>
<div> <div>
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</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 style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
</div> </div>
</div>`).join("")}async function y(){try{const b=await(await fetch("/api/v1/license/status")).json();b.success&&(D(b.license),U(b.license))}catch(I){console.warn("Failed to load license status:",I.message)}}async function B(){const I=x.value.trim();if(!I){S("Please enter a license code.");return}z(),j.disabled=!0,j.textContent="Activating...";try{const L=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:I})})).json();L.success?(T(L.message),x.value="",D(L.license),showNotification("License activated! Premium features unlocked.","success",5e3),U(L.license)):S(L.error||"Activation failed")}catch(b){S("Network error: "+b.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function $(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){g.disabled=!0,g.textContent="Deactivating...";try{const b=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();b.success?(T(b.message),await y(),showNotification("License deactivated.","info",3e3),U({active:!1})):S(b.error||"Deactivation failed")}catch(I){S("Network error: "+I.message)}finally{g.disabled=!1,g.textContent="Deactivate"}}}function U(I){const b=document.getElementById("license-status-topbar"),L=document.getElementById("license-topbar-icon"),l=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(b)if(b.className="license-status-topbar "+(I.active?"premium":"free"),I.active)if(L.textContent="\u2605",l.textContent="PREMIUM",I.lifetime)c.textContent="\xB7 LIFETIME";else{const d=I.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else L.textContent="\u2606",l.textContent=I.expired?"EXPIRED":"FREE TIER",c.textContent=""}function m(){z(),y(),w.classList.add("show")}x.addEventListener("input",function(){let I=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(I.length>this._prevLength&&(I=I.replace(/-/g,""),I.length>2&&!I.startsWith("DC")&&(I="DC"+I),I.startsWith("DC")&&I.length>2)){const b=["DC"],L=I.substring(2);for(let l=0;l<L.length;l+=5)b.push(L.substring(l,l+5));I=b.join("-")}this._prevLength=I.length,this.value=I}),j.addEventListener("click",B),g.addEventListener("click",$),x.addEventListener("keydown",I=>{I.key==="Enter"&&B()}),wireModal(w,document.getElementById("license-cancel"));const v=document.getElementById("license-status-topbar");v&&v.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=m,window.checkPremiumFeature=async function(I){try{return(await(await fetch(`/api/v1/license/feature/${I}`)).json()).available}catch{return!1}},y().then(I=>{k&&U(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 y(){try{const E=await(await fetch("/api/v1/license/status")).json();E.success&&(D(E.license),F(E.license))}catch(B){console.warn("Failed to load license status:",B.message)}}async function T(){const B=h.value.trim();if(!B){k("Please enter a license code.");return}z(),j.disabled=!0,j.textContent="Activating...";try{const u=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:B})})).json();u.success?(C(u.message),h.value="",D(u.license),showNotification("License activated! Premium features unlocked.","success",5e3),F(u.license)):k(u.error||"Activation failed")}catch(E){k("Network error: "+E.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function $(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){g.disabled=!0,g.textContent="Deactivating...";try{const E=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();E.success?(C(E.message),await y(),showNotification("License deactivated.","info",3e3),F({active:!1})):k(E.error||"Deactivation failed")}catch(B){k("Network error: "+B.message)}finally{g.disabled=!1,g.textContent="Deactivate"}}}function F(B){const E=document.getElementById("license-status-topbar"),u=document.getElementById("license-topbar-icon"),o=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(E)if(E.className="license-status-topbar "+(B.active?"premium":"free"),B.active)if(u.textContent="\u2605",o.textContent="PREMIUM",B.lifetime)c.textContent="\xB7 LIFETIME";else{const d=B.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else u.textContent="\u2606",o.textContent=B.expired?"EXPIRED":"FREE TIER",c.textContent=""}function S(){z(),y(),b.classList.add("show")}h.addEventListener("input",function(){let B=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(B.length>this._prevLength&&(B=B.replace(/-/g,""),B.length>2&&!B.startsWith("DC")&&(B="DC"+B),B.startsWith("DC")&&B.length>2)){const E=["DC"],u=B.substring(2);for(let o=0;o<u.length;o+=5)E.push(u.substring(o,o+5));B=E.join("-")}this._prevLength=B.length,this.value=B}),j.addEventListener("click",T),g.addEventListener("click",$),h.addEventListener("keydown",B=>{B.key==="Enter"&&T()}),wireModal(b,document.getElementById("license-cancel"));const I=document.getElementById("license-status-topbar");I&&I.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=S,window.checkPremiumFeature=async function(B){try{return(await(await fetch(`/api/v1/license/feature/${B}`)).json()).available}catch{return!1}},y().then(B=>{x&&F(x)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const b=[{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 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;"> <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> <h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
@@ -2256,7 +2256,7 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
<div class="share-tab-panel" data-panel="public"> <div class="share-tab-panel" data-panel="public">
<label class="form-label-bold" for="share-public-ttl">Link duration:</label> <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;"> <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(e=>`<option value="${e.ms}">${e.label}</option>`).join("")} ${b.map(e=>`<option value="${e.ms}">${e.label}</option>`).join("")}
</select> </select>
<button id="share-public-create" class="btn-accent" type="button" <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;"> style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
@@ -2303,17 +2303,17 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
</div> </div>
</div> </div>
</div> </div>
`);const x=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),g=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),M=document.getElementById("share-issued-meta"),R=document.getElementById("share-error"),h=document.getElementById("share-success"),C=document.getElementById("share-outstanding-list"),E=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),z=document.getElementById("share-ts-email"),S=document.getElementById("share-public-ttl");let T=null,D="public";function y(){R.style.display="none",h.style.display="none",g.style.display="none"}function B(e){h.style.display="none",g.style.display="none",R.textContent=e,R.style.display="block"}function $(e){R.style.display="none",h.textContent=e,h.style.display="block"}function U(){return window.location.origin}function m(e){return e.startsWith("http")?e:U()+e}function v(e){if(!e)return"";const s=new Date(e);return Number.isNaN(s.getTime())?"":s.toLocaleString()}function I(e){if(e<=0)return"expired";const s=Math.floor(e/36e5);if(s>=24)return`${Math.floor(s/24)}d ${s%24}h left`;const r=Math.floor(e%36e5/6e4);return`${s}h ${r}m left`}function b(e){D=e,y(),x.querySelectorAll(".share-tab").forEach(s=>{const r=s.dataset.tab===e;s.classList.toggle("active",r),s.style.borderBottomColor=r?"var(--accent)":"transparent",s.style.color=r?"var(--fg)":"var(--muted)",s.style.fontWeight=r?"600":"400"}),x.querySelectorAll(".share-tab-panel").forEach(s=>{s.style.display=s.dataset.panel===e?"":"none"})}async function L(){C.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const e=await fetch("/api/v1/share",{credentials:"same-origin"});if(!e.ok){C.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const s=await e.json(),o=(s&&s.data||[]).filter(a=>a.serviceId===T.id);if(o.length===0){C.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}C.innerHTML=o.map(a=>{const t=a.expiresAt?I(new Date(a.expiresAt).getTime()-Date.now()):"";return` `);const h=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),g=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),M=document.getElementById("share-issued-meta"),R=document.getElementById("share-error"),f=document.getElementById("share-success"),L=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),x=document.getElementById("share-ts-create"),z=document.getElementById("share-ts-email"),k=document.getElementById("share-public-ttl");let C=null,D="public";function y(){R.style.display="none",f.style.display="none",g.style.display="none"}function T(e){f.style.display="none",g.style.display="none",R.textContent=e,R.style.display="block"}function $(e){R.style.display="none",f.textContent=e,f.style.display="block"}function F(){return window.location.origin}function S(e){return e.startsWith("http")?e:F()+e}function I(e){if(!e)return"";const i=new Date(e);return Number.isNaN(i.getTime())?"":i.toLocaleString()}function B(e){if(e<=0)return"expired";const i=Math.floor(e/36e5);if(i>=24)return`${Math.floor(i/24)}d ${i%24}h left`;const l=Math.floor(e%36e5/6e4);return`${i}h ${l}m left`}function E(e){D=e,y(),h.querySelectorAll(".share-tab").forEach(i=>{const l=i.dataset.tab===e;i.classList.toggle("active",l),i.style.borderBottomColor=l?"var(--accent)":"transparent",i.style.color=l?"var(--fg)":"var(--muted)",i.style.fontWeight=l?"600":"400"}),h.querySelectorAll(".share-tab-panel").forEach(i=>{i.style.display=i.dataset.panel===e?"":"none"})}async function u(){L.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const e=await fetch("/api/v1/share",{credentials:"same-origin"});if(!e.ok){L.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const i=await e.json(),s=(i&&i.data||[]).filter(a=>a.serviceId===C.id);if(s.length===0){L.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}L.innerHTML=s.map(a=>{const t=a.expiresAt?B(new Date(a.expiresAt).getTime()-Date.now()):"";return`
<div class="share-row" data-share-id="${escapeHtml(a.id)}" <div class="share-row" data-share-id="${escapeHtml(a.id)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);"> style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;"> <span style="flex: 1;">
<strong>${a.kind==="tailscale"?"Tailscale":"Public"}</strong> <strong>${a.kind==="tailscale"?"Tailscale":"Public"}</strong>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(v(a.expiresAt))} (${t})</span> <span style="color: var(--muted);"> \xB7 expires ${escapeHtml(I(a.expiresAt))} (${t})</span>
</span> </span>
<button class="share-revoke" data-share-id="${escapeHtml(a.id)}" type="button" <button class="share-revoke" data-share-id="${escapeHtml(a.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;"> 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 Revoke
</button> </button>
</div> </div>
`}).join(""),C.querySelectorAll(".share-revoke").forEach(a=>{a.addEventListener("click",()=>l(a.dataset.shareId))})}catch{C.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function l(e){try{const s=await fetch(`/api/v1/share/${encodeURIComponent(e)}`,{method:"DELETE",credentials:"same-origin"});if(!s.ok){const r=await s.json().catch(()=>({}));B(r&&r.error||`Revoke failed (HTTP ${s.status}).`);return}$("Share revoked."),L()}catch{B("Network error while revoking.")}}async function c(){if(!T)return;y(),H.disabled=!0;const e=H.textContent;H.textContent="Creating\u2026";try{const s=parseInt(S.value,10),r=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,ttlMs:s})}),o=await r.json().catch(()=>({}));if(!r.ok||!o.success){f(r.status,o);return}const a=o.data.urlPath;N.value=m(a),M.textContent=`Public link \xB7 expires ${v(o.data.expiresAt)}`,g.style.display="block",$("Share link created."),L()}catch{B("Network error while creating share.")}finally{H.disabled=!1,H.textContent=e}}async function d(){if(!T)return;const e=(z.value||"").trim();if(!e||!e.includes("@")){B("A valid recipient email is required.");return}y(),k.disabled=!0;const s=k.textContent;k.textContent="Creating\u2026";try{const r=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,email:e})}),o=await r.json().catch(()=>({}));if(!r.ok||!o.success){f(r.status,o);return}const a=o.data&&o.data.urlPath;a?(N.value=m(a),M.textContent=`Tailscale invite \xB7 emailed to ${e} (or use this URL manually if delivery failed).`):(N.value="",M.textContent=`Tailscale invite sent to ${e}.`),g.style.display="block",$("Tailscale invite created."),z.value="",L()}catch{B("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=s}}function f(e,s){const r=s&&s.error||"";if(e===402){B("Share is a Pro feature. Activate a license to unlock.");return}if(e===400&&/tailscale/i.test(r)){B("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(e===403){B("You do not have permission to share services.");return}if(e===404){B("Service not found. It may have been deleted.");return}B(r||`Request failed (HTTP ${e}).`)}function p(e){!e||!e.id||(T={id:e.id,name:e.name||e.id},j.textContent=T.name,y(),b("public"),z.value="",S.value=String(w[1].ms),x.classList.add("show"),L())}x.querySelectorAll(".share-tab").forEach(e=>{e.addEventListener("click",()=>b(e.dataset.tab))}),H.addEventListener("click",c),k.addEventListener("click",d),E.addEventListener("click",()=>x.classList.remove("show")),wireModal(x,E),P.addEventListener("click",async()=>{const e=N.value;if(e)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(e):(N.select(),document.execCommand("copy"));const s=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=s},1200)}catch{B("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=p})(); `}).join(""),L.querySelectorAll(".share-revoke").forEach(a=>{a.addEventListener("click",()=>o(a.dataset.shareId))})}catch{L.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function o(e){try{const i=await fetch(`/api/v1/share/${encodeURIComponent(e)}`,{method:"DELETE",credentials:"same-origin"});if(!i.ok){const l=await i.json().catch(()=>({}));T(l&&l.error||`Revoke failed (HTTP ${i.status}).`);return}$("Share revoked."),u()}catch{T("Network error while revoking.")}}async function c(){if(!C)return;y(),H.disabled=!0;const e=H.textContent;H.textContent="Creating\u2026";try{const i=parseInt(k.value,10),l=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,ttlMs:i})}),s=await l.json().catch(()=>({}));if(!l.ok||!s.success){v(l.status,s);return}const a=s.data.urlPath;N.value=S(a),M.textContent=`Public link \xB7 expires ${I(s.data.expiresAt)}`,g.style.display="block",$("Share link created."),u()}catch{T("Network error while creating share.")}finally{H.disabled=!1,H.textContent=e}}async function d(){if(!C)return;const e=(z.value||"").trim();if(!e||!e.includes("@")){T("A valid recipient email is required.");return}y(),x.disabled=!0;const i=x.textContent;x.textContent="Creating\u2026";try{const l=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,email:e})}),s=await l.json().catch(()=>({}));if(!l.ok||!s.success){v(l.status,s);return}const a=s.data&&s.data.urlPath;a?(N.value=S(a),M.textContent=`Tailscale invite \xB7 emailed to ${e} (or use this URL manually if delivery failed).`):(N.value="",M.textContent=`Tailscale invite sent to ${e}.`),g.style.display="block",$("Tailscale invite created."),z.value="",u()}catch{T("Network error while creating Tailscale invite.")}finally{x.disabled=!1,x.textContent=i}}function v(e,i){const l=i&&i.error||"";if(e===402){T("Share is a Pro feature. Activate a license to unlock.");return}if(e===400&&/tailscale/i.test(l)){T("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(e===403){T("You do not have permission to share services.");return}if(e===404){T("Service not found. It may have been deleted.");return}T(l||`Request failed (HTTP ${e}).`)}function p(e){!e||!e.id||(C={id:e.id,name:e.name||e.id},j.textContent=C.name,y(),E("public"),z.value="",k.value=String(b[1].ms),h.classList.add("show"),u())}h.querySelectorAll(".share-tab").forEach(e=>{e.addEventListener("click",()=>E(e.dataset.tab))}),H.addEventListener("click",c),x.addEventListener("click",d),w.addEventListener("click",()=>h.classList.remove("show")),wireModal(h,w),P.addEventListener("click",async()=>{const e=N.value;if(e)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(e):(N.select(),document.execCommand("copy"));const i=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=i},1200)}catch{T("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=p})();
//# 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 //# 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
+2 -1
View File
@@ -506,9 +506,10 @@
<select id="setup-dns-provider" class="form-input-lg" style="margin-bottom: 12px;"> <select id="setup-dns-provider" class="form-input-lg" style="margin-bottom: 12px;">
<option value="technitium">Technitium DNS (recommended)</option> <option value="technitium">Technitium DNS (recommended)</option>
<option value="cloudflare">Cloudflare DNS</option> <option value="cloudflare">Cloudflare DNS</option>
<option value="rfc2136">RFC 2136 (BIND / PowerDNS / other)</option> <option value="rfc2136">RFC 2136 (BIND 9 / PowerDNS / other)</option>
<option value="manual">Manual / External DNS</option> <option value="manual">Manual / External DNS</option>
</select> </select>
<div id="setup-dns-provider-hints" class="form-hint" style="margin-bottom: 12px; line-height: 1.5;"></div>
<div style="display: grid; grid-template-columns: 1fr auto; gap: 8px;"> <div style="display: grid; grid-template-columns: 1fr auto; gap: 8px;">
<input type="text" id="setup-dns-ip" value="" placeholder="DNS server IP" <input type="text" id="setup-dns-ip" value="" placeholder="DNS server IP"
style="padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" /> style="padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
+29
View File
@@ -452,4 +452,33 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
location.reload(); location.reload();
} }
}; };
// ── DC-171: capability reporting at DNS provider CHOICE time ────────────
// Per-provider honest capability hints. Keep in sync with the adapters'
// getCapabilities() in dashcaddy-api/src/dns/dns-providers/ — the parity
// test (dns-provider-parity.test.js) asserts every <option> value here has
// a hint entry.
const DNS_PROVIDER_HINTS = {
technitium: '✅ Full management — records, zones and health checks via the Technitium API. DashCaddy can create, delete, list and resolve records.',
cloudflare: '✅ Full management — records via the Cloudflare API token. DashCaddy can create, delete, list and resolve records.',
rfc2136: '⚠️ Partial (push-only protocol) — records can be created, deleted and resolved, and listed via AXFR zone transfer if the server permits transfers for your key (BIND: allow-transfer { key <name>; };). Listing requires that server-side setting.',
manual: 'ℹ️ No API control — DashCaddy shows the records you create yourself with your DNS provider\'s tools. Everything else (reverse proxy, SSL) works normally.',
};
function updateDnsProviderHints() {
const select = document.getElementById('setup-dns-provider');
const hints = document.getElementById('setup-dns-provider-hints');
if (!select || !hints) return;
hints.innerHTML = DNS_PROVIDER_HINTS[select.value]
? `<strong>${escapeHint(select.selectedOptions[0]?.text || select.value)}</strong><br>${DNS_PROVIDER_HINTS[select.value]}`
: '';
}
function escapeHint(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const dnsProviderSelect = document.getElementById('setup-dns-provider');
if (dnsProviderSelect) {
dnsProviderSelect.addEventListener('change', updateDnsProviderHints);
updateDnsProviderHints();
}
})(); })();
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-36d9aa3364'; const CACHE = 'dashcaddy-shell-b87d8c8095';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',