[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]
### 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
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 —
@@ -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,
templates, MCP tools measured at runtime), so the marketing site, docs, and
llms.txt can never disagree with the product.
- **DC-171 (ticketed, pending): DNS provider plug-in parity** — capability
reporting at provider-choice time (RFC2136 is push-only: create/resolve yes,
listing is protocol-impossible); BIND 9 near-parity via the RFC2136 adapter
plus AXFR-based listing; PowerDNS/deSEC/AdGuard Home adapters on the roadmap.
- **DC-171 (roadmap): further provider plug-in parity** — BIND 9 AXFR-based
listing shipped in phase 1 (above); remaining roadmap: PowerDNS /
deSEC / AdGuard Home adapters, richer per-provider setup validation.
~~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
- 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 () => {
// RFC2136-style: push-only protocol, cannot enumerate records
const dns = { getActiveProvider: () => ({ supportsCapability: () => false }) };
// RFC2136-style: push-only protocol, cannot enumerate records.
// 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');
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 () => {
+10 -1
View File
@@ -73,7 +73,16 @@ module.exports = function({
const zone = req.query.zone || (siteConfig.tld ? String(siteConfig.tld).replace(/^\./, '') : '');
const provider = dns.getActiveProvider ? dns.getActiveProvider() : null;
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 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.
* Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server.
*
* Capabilities: create-record, delete-record, resolve, credentials
* Not supported: logs, restart, update-check, list-records, zones
* Capabilities: create-record, delete-record, resolve, list-records (AXFR), credentials
* 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');
@@ -20,7 +27,7 @@ const execFileAsync = promisify(execFile);
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_TSIG_ALGORITHM = 'hmac-sha256';
@@ -34,7 +41,7 @@ class RFC2136Provider extends BaseDNSProvider {
super(config, ctx);
this.providerId = 'rfc2136';
this.displayName = 'RFC 2136 (Dynamic DNS)';
this.displayName = 'RFC 2136 / BIND 9 (Dynamic DNS)';
// Core config
this.server = config.server || null;
@@ -56,8 +63,10 @@ class RFC2136Provider extends BaseDNSProvider {
}
}
// Logger shorthand
this._log = ctx && ctx.log ? ctx.ctx : null;
// NOTE: no logger shorthand here — the _log() prototype method (below)
// 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 ────────────────────────────────────────────────────────
@@ -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 ───────────────────────────────────────────────────────
/**
+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
let path = '/dns/records';
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': {
+221 -221
View File
@@ -90,44 +90,44 @@
<button id="logo-save" class="btn-accent">Save</button>
</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>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${b}</div>
<div><strong>Certificate Authority:</strong> ${L}</div>
<div><strong>DNS Server:</strong> ${l}:${c}</div>
<div><strong>Example URLs:</strong> https://uptime${b}, https://nextcloud${b}</div>
<div><strong>TLD:</strong> ${d}</div>
<div><strong>Certificate Authority:</strong> ${v}</div>
<div><strong>DNS Server:</strong> ${p}:${e}</div>
<div><strong>Example URLs:</strong> https://uptime${d}, https://nextcloud${d}</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>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<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>Example URLs:</strong> http://${b}:8080, http://${b}:3000</div>
<div><strong>Example URLs:</strong> http://${d}:8080, http://${d}:3000</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>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${b}</div>
<div><strong>Domain:</strong> ${d}</div>
<div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${L}</div>
<div><strong>Routing:</strong> ${l==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${c}</div>
<div><strong>Email:</strong> ${v}</div>
<div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${e}</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="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>
`,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">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button>
</div>
</div>
</div>`);const 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 class="app-option-icon">${escapeHtml(s.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(s.name)}</div>
<div class="app-option-desc">${escapeHtml(s.description||"")}</div>
</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(i.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(i.name)}</div>
<div class="app-option-desc">${escapeHtml(i.description||"")}</div>
${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}
Status: ${Y.status}
@@ -347,38 +347,38 @@ Port: ${Y.primaryPort||"N/A"}
Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(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}"
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>
<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>
`;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: var(--muted); margin-left: 8px;">${V.self?.hostname} (${V.self?.ip})</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!
URL: ${o.url}`:`${c.name} deployed successfully!
URL: ${o.url}`;o.warning&&(t+=`
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: ${s.url}`:`${c.name} deployed successfully!
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(`
`);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.
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;">
\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(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 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="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="flex: 1;">${escapeHtml(p.name)}</span>
${s?'<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=>`
${i?'<span style="color: var(--success);">\u2713</span>':""}
</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;">
${escapeHtml(l)}
<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>
${escapeHtml(o)}
<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>
`).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;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="recipe-next" class="btn-accent-solid">Next</button>
</div>
</div>
</div>`);let w=null,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}
<div class="app-option-icon">${escapeHtml(L.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(L.name)}</div>
<div class="app-option-desc">${escapeHtml(L.description||"")}</div>
<div class="app-option-icon">${escapeHtml(u.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(u.name)}</div>
<div class="app-option-desc">${escapeHtml(u.description||"")}</div>
${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=`
<input type="checkbox" ${L?"checked disabled":"checked"} data-component-id="${I.id}"
`,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" ${u?"checked disabled":"checked"} data-component-id="${B.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" />
<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);">
${I.templateRef?escapeHtml(I.templateRef):"Built-in"}
${L?'<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>':""}
${B.templateRef?escapeHtml(B.templateRef):"Built-in"}
${u?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${o?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</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>
`,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=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(L.label||b)}</label>
<input type="text" data-volume-key="${b}" value="${escapeHtml(L.defaultPath||"")}"
placeholder="${escapeHtml(L.defaultPath||"/path")}"
`,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(u.label||E)}</label>
<input type="text" data-volume-key="${E}" value="${escapeHtml(u.defaultPath||"")}"
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;" />
<div class="form-hint-sm">${escapeHtml(L.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=`
<div class="form-hint-sm">${escapeHtml(u.description||"")}</div>
`,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="color: var(--muted); margin-bottom: 16px;">${escapeHtml(j.description||"")}</div>
<div style="margin-bottom: 12px;">
<strong>Components (${v.length}):</strong>
<strong>Components (${I.length}):</strong>
<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>':""}
</div>`).join("")}
</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>
${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 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>
${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 style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</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="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(d.message||"Deployed!")}</div>
${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${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>
`,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")}
</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)}
</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 {
flex: 1;
text-align: center;
@@ -550,7 +550,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel {
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;">
<h3>\u{1F4CB} Error Logs</h3>
<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>
</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(`
`)[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;">
</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],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="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
@@ -648,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>
</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 class="log-entry ${u}" data-index="${i}" 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>
</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 ${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;">${r+1}</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>
`}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(`
`),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",`
`}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),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 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;">
@@ -698,7 +698,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</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;">
<h3>\u{1F4BE} Container Snapshots</h3>
<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>
</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;">
<h3>\u{1F3AC} Smart Arr Connect</h3>
<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>
</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);">
<span style="font-size: 1.1rem;">${f}</span>
</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;">${v}</span>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${p}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${e} ${s} ${r}
${e} ${i} ${l}
</div>
</div>
${B(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;">
${escapeHtml(String(l.fullyConnected))}/${escapeHtml(String(l.totalDetected+(5-l.totalDetected)))} services detected &middot;
${escapeHtml(String(l.fullyConnected))} connected${l.needsApiKey>0?` &middot; <strong>${escapeHtml(String(l.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)"};">
${T(d.status)}
</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(o.fullyConnected))}/${escapeHtml(String(o.totalDetected+(5-o.totalDetected)))} services detected &middot;
${escapeHtml(String(o.fullyConnected))} connected${o.needsApiKey>0?` &middot; <strong>${escapeHtml(String(o.needsApiKey))} needs API key</strong>`:""}
</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;">
<span style="font-size: 1.1rem;">${s}</span>
<span style="font-weight: 500;">${r}</span>
<span style="font-size: 1.1rem;">${i}</span>
<span style="font-weight: 500;">${l}</span>
<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>
</div>
<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>
<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;" />
</div>
</div>
<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;">
<span style="font-size: 1.1rem;">\u{1F3AC}</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>
</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;">
Configured: ${s.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${s.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${s.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>`}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: ${i.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${i.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${i.plex?"&#10003; Plex":"&#10007; Plex"}
</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;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span>
${B(f.status)}
${T(v.status)}
</div>
${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}
<span>${escapeHtml(f.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${e};">${escapeHtml(f.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;">
<span>${escapeHtml(v.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${e};">${escapeHtml(v.details||"")}</span>
</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.1rem; font-weight: 600; color: ${c};">${f}</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>`;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;">
${s} ${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);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(L.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="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(u.succeeded))} succeeded, ${escapeHtml(String(u.failed))} failed</div>
</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;">
${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=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(u.serverName)} Libraries</h4>
<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>
<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;">
<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>
</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;">
<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="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>
</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;">
<h3>\u{1F4BE} Backup & Restore</h3>
<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>
</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;">
<h3>\u{1F4CA} Resource Monitor</h3>
<p class="modal-subtitle">
@@ -1305,21 +1305,21 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("stats-modal"),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="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.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 style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
<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="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>
<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>
@@ -1330,7 +1330,7 @@ This will replace current configuration, credentials, and data. Containers will
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
</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 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>`}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="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">${s.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">${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.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?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</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">${i.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></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>`:""}
</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`
${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>`)}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}">
<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-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-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="checkbox" class="alert-autorestart" ${i.autoRestart?"checked":""} ${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="${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="${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" ${r.autoRestart?"checked":""} ${v?"":"disabled"} /></td>
<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>
</td>
</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>
<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; 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; 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>
</tr>
`}).join(""),a=f?`
`}).join(""),a=v?`
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
@@ -1388,7 +1388,7 @@ This will replace current configuration, credentials, and data. Containers will
<th style="padding: 6px 8px;"></th>
</tr>
</thead>
<tbody>${r}</tbody>
<tbody>${l}</tbody>
</table>
</div>
<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}
<div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
${o?`
${s?`
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
<thead>
@@ -1418,26 +1418,26 @@ This will replace current configuration, credentials, and data. Containers will
<th style="padding: 6px 8px;"></th>
</tr>
</thead>
<tbody>${o}</tbody>
<tbody>${s}</tbody>
</table>
</div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const 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="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-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>
<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}" />
</svg>
</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;">
${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>
`;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;">
<h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle">
@@ -1561,7 +1561,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("health-modal"),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><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>
@@ -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);">Up Checks</span><br><strong style="color: var(--ok-fg);">${q.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${q.downChecks||0}</strong></div>
</div>`}else O.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){O.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(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;">
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
<span>${f(_.severity)}</span>
<span>${v(_.severity)}</span>
</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>`;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;">
<h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle">
@@ -1652,17 +1652,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button>
</div>
</div>
</div>`);const w=document.getElementById("updates-modal"),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
<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;">
</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(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="daily"${O==="daily"?" selected":""}>Daily</option>
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</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
`+s.join(`
</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
`+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;">
<h3>\u{1F433} Docker Resources</h3>
<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>
</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;">
<h3>\u{1F4E6} Import Docker Compose</h3>
<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>
</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-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<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>
</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
\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[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;">
<h3>\u{1F4DC} Audit Log</h3>
<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>
</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;">
<h3>\u{1F6E1}\uFE0F Security Center</h3>
<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>
</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;">
<span style="color:${d};font-weight:600;">${b(c)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${b(p)}</span>
<span title="${b(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b(e)}</span>
<span title="${b(s)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b(r)} ${b(s)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${b(o)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${b(f)}</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>`);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;">${E(c)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${E(p)}</span>
<span title="${E(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${E(e)}</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;">${E(s)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${E(v)}</span>
</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>
<strong>${b(f.label||f.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${b(f.type)}</span>
<strong>${E(v.label||v.id)}</strong>
<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;">
id: ${b(f.id)} \xB7
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"}
id: ${E(v.id)} \xB7
registered ${new Date(v.registered_at).toLocaleDateString()} \xB7
last seen ${v.last_seen_at?new Date(v.last_seen_at).toLocaleString():"never"}
</div>
</div>
<div>
<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>`}).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}
label: ${p.data.host.label}
@@ -1922,7 +1922,7 @@ type: ${p.data.host.type}
${p.data.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;">
<h3>\u{1F69A} Deploys</h3>
<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>
</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+`
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+`
`:"")+"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
Card: `+(l.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+`
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?`
</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 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 "+(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: `+(o.url||"")+`
`+(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: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`:`
\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`:`
\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>
<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>
@@ -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>
</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
<circle cx="${n}" cy="${i}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F}
<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="${r}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${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="${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="${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"/>
<circle cx="${n}" cy="${i}" 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">
<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="${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="${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="${r}" r="3" fill="var(--fg)"/>
</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}">
<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;">
<h3>Clock Settings</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${L}
${u}
</div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -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>
</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">
<h3 id="calendar-modal-title">\u{1F4C5} Calendar</h3>
<div class="cal-monthbar">
@@ -2118,18 +2118,18 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
</details>
</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(`
`);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">
<span class="cal-when">${x(g(i.start,i.allDay))}</span>
<span class="cal-evtitle">${x(i.title||"Untitled")}</span>
${i.location?`<span class="cal-loc">${x(i.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></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="${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">${h(g(r.start,r.allDay))}</span>
<span class="cal-evtitle">${h(r.title||"Untitled")}</span>
${r.location?`<span class="cal-loc">${h(r.location)}</span>`:""}
</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">
<span class="cal-evtitle">${x(n.name||n.id)}
<span class="cal-loc">\xB7 ${x(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-del cal-btn cal-btn-danger" data-id="${x(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">
<span class="cal-evtitle">${h(n.name||n.id)}
<span class="cal-loc">\xB7 ${h(n.type)}${n.autoProvisioned?" \xB7 auto-wired":""}</span></span>
<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="${h(n.id)}">remove</button>
</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;">
<h3>\u{1F916} Connect your AI assistant</h3>
<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>
<button id="aiconn-close">Close</button>
</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;">
<span style="flex:1;">${escapeHtml(k.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>
<button class="aiconn-revoke" data-id="${escapeHtml(k.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
<span style="flex:1;">${escapeHtml(x.name)}
<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(x.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button>
</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;">
<h3>DashCaddy License</h3>
<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>
</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>Code: <code style="font-family: monospace;">${I.code||"***"}</code></div>
${l}
`,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);">
<span style="font-size: 1.1rem;">${L.has(l)?"\u2705":"\u{1F512}"}</span>
</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;">${B.code||"***"}</code></div>
${o}
`,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;">${u.has(o)?"\u2705":"\u{1F512}"}</span>
<div>
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
</div>
</div>`).join("")}async function 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 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>
@@ -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">
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
${w.map(e=>`<option value="${e.ms}">${e.label}</option>`).join("")}
${b.map(e=>`<option value="${e.ms}">${e.label}</option>`).join("")}
</select>
<button id="share-public-create" class="btn-accent" type="button"
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
@@ -2303,17 +2303,17 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
</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)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;">
<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>
<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;">
Revoke
</button>
</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})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
`}).join(""),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
+2 -1
View File
@@ -506,9 +506,10 @@
<select id="setup-dns-provider" class="form-input-lg" style="margin-bottom: 12px;">
<option value="technitium">Technitium DNS (recommended)</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>
</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;">
<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;" />
+29
View File
@@ -452,4 +452,33 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
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 = [
'/',
'/index.html',