diff --git a/BACKLOG.md b/BACKLOG.md index c4ce43f..3b45879 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -271,11 +271,12 @@ Tickets DC-033 through DC-041 were added after the DNS2 v1.14.4 / v1.14.8 / 0.0. - **result:** Shipped as part of DC-046 commit. `src/auth/providers/email.js` (388 LOC): registers `magic-link` (initiate) + `verify-token` (verify) methods, generates 32-byte base64url tokens, stores SHA-256 hashes via `email-tokens-store.js`. `email-tokens-store.js` (260 LOC): atomic lockfile-based mutation, automatic cleanup of expired tokens, audit log on every issue/use. `email-sender.js` (67 LOC): wraps nodemailer if `providers.email` config is set, else falls back to `log.info('auth', 'email magic link issued', ...)` so dev installs work without SMTP config. Verified with stub deps: `initiate()` writes a token + logs `deliveredVia: 'dev-console'` + returns masked email; `verify('verify-token', { token: 'garbage' })` throws AuthenticationError (route handler converts to 401). Real SMTP wiring takes effect as soon as `providers.email.host/port/username/password` are set in config.json. ### DC-048: Multi-user bootstrap + admin invites -- **status:** todo -- **owner:** unclaimed +- **status:** done +- **owner:** hermes - **details:** The user model shifts from "one implicit operator" to "many users with explicit roles". Bootstrap rule: the FIRST email to ever successfully log in via email magic link becomes the admin. Subsequent emails are denied with a `not authorized` error UNLESS the email appears in `data/authorized-users.json`. Admin UI: a `/users` page that lists authorized users, lets admin add emails (manual entry) or generate single-use invite links (which work like magic links but pre-add the email to the allowlist on first use). Audit log gets `userEmail` attribution on every entry. License model is unchanged (still per-host) but note in the ticket that this may need revisiting. Effort: ~2 hrs. Risk: low (mostly UI + JSON-store CRUD). - **impact:** First real multi-user DashCaddy. Per-user audit attribution. Self-service invites. Foundation for any future "team" features. - **prerequisite:** DC-047 (needs email auth working first). +- **result:** Shipped as opt-in. Email auth must be explicitly enabled via `siteConfig.authProviders.email.enabled = true`; single-user TOTP-only installs see zero behavior change. New modules: `src/security/user-store.js` (users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, 380 LOC), `src/security/invite-store.js` (single-use tokens, SHA-256 hashed on disk, TTL, 230 LOC). New routes: `routes/auth/admin.js` (`/me`, `/admin/users` GET/POST/PATCH/DELETE, `/admin/allowlist`, `/admin/invites` GET/POST/DELETE, public `/invites/:token` peek + `/invites/:token/accept` redeem, 360 LOC). EmailMagicLinkProvider `verify()` calls `userStore.isEmailAuthorized()` then `userStore.login()` then tags `req.user` for audit attribution; TOTP `verify()` bootstraps a `system@totp.local` admin record on first login so the current operator shows up in `/admin/users` without a re-login. Audit logger middleware reads `req.user` and adds `userId`/`userEmail`/`userRole`/`viaProvider` to log details. New admin UI: `status/js/admin.js` (modal overlay, users list with role-edit + delete, invite form with copy-link button, outstanding-invites list with revoke). Wired into `core/init.js` so the "Admin" trigger button appears in the top bar only when `/me` returns `isAdmin: true`. 35 new tests across 3 files. Full suite: 1298/1298. Update PUBLIC_ROUTES + CSRF allowlists for the new invite redemption paths (same exemption rationale as login verify). ### Backlog note (2026-07-20, hermes) diff --git a/CHANGELOG.md b/CHANGELOG.md index 008ac49..f441a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Multi-user bootstrap + admin invites — DC-048.** Opt-in via `siteConfig.authProviders.email.enabled = true`. Single-user TOTP-only installs see zero behavior change. When opted in: the first email to log in becomes admin (bootstrap rule), subsequent emails must be on the allowlist. New `src/security/user-store.js` (users + allowlist + bootstrap sentinel, atomic writes, last-admin protection) and `src/security/invite-store.js` (single-use tokens, SHA-256 hashed on disk, TTL, auto-prune). New `routes/auth/admin.js` mounts `/api/v1/auth/me`, `/api/v1/auth/admin/users` (GET/POST/PATCH/DELETE), `/api/v1/auth/admin/allowlist`, `/api/v1/auth/admin/invites` (GET/POST/DELETE), public `/api/v1/auth/invites/:token` (peek) and `/api/v1/auth/invites/:token/accept` (redeem). EmailMagicLinkProvider `verify()` and TOTP `verify()` tag `req.user` for audit attribution; TOTP bootstraps a `system@totp.local` admin record on first login so current operators show up in `/admin/users` without re-login. Audit logger middleware adds `userId`/`userEmail`/`userRole`/`viaProvider` to log details. New admin UI in `status/js/admin.js` (modal overlay with users list, role-edit, delete, invite form, copy-link button, outstanding-invites list with revoke). "Admin" button auto-injects into the top bar when `/me` returns `isAdmin: true`. 35 new tests; full suite 1298/1298. - **Pluggable auth UI — DC-049.** `status/js/auth-gate.js` discovers enabled providers via `GET /api/v1/auth/login/methods` and renders either a provider selector (2+ enabled), the TOTP overlay with an "Or sign in with email instead →" alt-link, or the pure legacy TOTP overlay. Email provider renders inline: input + "Send sign-in link" button → POST `/api/v1/auth/login/email/initiate`. Coordination flag `window.__dc_049_handled` eliminates flicker on multi-provider installs. New SW cache hash `dashcaddy-shell-c550d0b371`. - **Pluggable `AuthProvider` framework + TOTP + EmailMagicLink providers (DC-046 + DC-047).** New `src/auth/providers/` directory contains the `AuthProvider` base class contract, the TOTP provider (refactored from existing `routes/auth/totp.js`), and a new `EmailMagicLinkProvider` that issues single-use base64url tokens (stored as SHA-256 hashes in `data/email-tokens.json`), sends via the existing nodemailer config (or logs to console + `log.info('email magic link issued')` in dev fallback). `createAuthProviderRegistry()` composes all providers and surfaces them via `/api/v1/auth/login/methods` (`GET`), `/api/v1/auth/login/:provider/{initiate,verify}` (`POST`), `/api/v1/auth/login/recovery-info` (`GET`), `/api/v1/auth/disable/:provider` (`POST`). Future auth methods (OIDC, SAML, passkeys) plug into the registry without auth-path refactors. - **`platform-paths.assertSafe()` — DC-046 hardening.** Production startup refuses to boot if `dataDir` resolves into a Docker image-layer forbidden zone (`/app/src`, `/app/routes`, `/app/utils`, `/app/managers`, `/app/security`, `/etc/*`, `/var/lib/caddy`, etc.). Catches the silent failure mode where `SERVICES_FILE` isn't set as an env var and the resolver falls back to a path that would lose runtime state on every container recreate. Bypassed with `SKIP_DATA_DIR_GUARD=1` for emergency legacy setups. diff --git a/dashcaddy-api/__tests__/auth-multistore-integration.test.js b/dashcaddy-api/__tests__/auth-multistore-integration.test.js new file mode 100644 index 0000000..cff0d5c --- /dev/null +++ b/dashcaddy-api/__tests__/auth-multistore-integration.test.js @@ -0,0 +1,374 @@ +/** + * Tests for DC-048 auth flow integration: + * - email login: first user = bootstrap admin (no allowlist needed) + * - email login: subsequent user without allowlist = rejected + * - email login: subsequent user with allowlist = operator role + * - email login: token consumption is atomic (replay = already_used) + * - TOTP login: tags req.user with system-admin record (audit attribution) + * - admin routes: /me returns the right shape + * - admin routes: 403 for non-admin on /admin/* + * - invite flow: issue → email → accept → user created with role + * + * Strategy: build the EmailMagicLinkProvider + a TOTP stub + the admin router + * with an in-process user store. No HTTP server; we call the handlers + * directly with mock req/res. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +function _tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-integration-')); +} +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +describe('DC-048: opt-in user store', () => { + let dir; + beforeEach(() => { dir = _tmpDir(); }); + afterEach(() => _cleanup(dir)); + + test('userStore is null until email auth is explicitly enabled', () => { + // The wiring code in routes/auth/index.js checks: + // siteConfig.authProviders.email.enabled === true + // If false, userStore stays null and providers fall back to legacy + // "allow everyone" semantics. This test simulates that branch by + // checking the flag path directly. + const siteConfig = { authProviders: { email: { enabled: false } } }; + const emailEnabled = + siteConfig.authProviders && siteConfig.authProviders.email && siteConfig.authProviders.email.enabled === true; + expect(emailEnabled).toBe(false); + }); + + test('userStore activates when email auth is explicitly enabled', () => { + const siteConfig = { authProviders: { email: { enabled: true } } }; + const emailEnabled = + siteConfig.authProviders && siteConfig.authProviders.email && siteConfig.authProviders.email.enabled === true; + expect(emailEnabled).toBe(true); + }); +}); + +describe('DC-048: email magic-link auth attribution', () => { + let dir, userStore; + beforeEach(() => { + dir = _tmpDir(); + userStore = require('../src/security/user-store').createUserStore({ dataDir: dir }); + }); + afterEach(() => _cleanup(dir)); + + test('first email = bootstrap admin', async () => { + const r = await userStore.login({ email: 'admin@example.com', ip: '127.0.0.1' }); + expect(r.ok).toBe(true); + expect(r.isBootstrap).toBe(true); + expect(r.role).toBe('admin'); + }); + + test('second email without allowlist rejected', async () => { + await userStore.login({ email: 'admin@example.com' }); + const r = await userStore.login({ email: 'stranger@example.com' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('not_authorized'); + }); + + test('second email WITH allowlist = operator role', async () => { + await userStore.login({ email: 'admin@example.com' }); + await userStore.addToAllowlist('friend@example.com'); + const r = await userStore.login({ email: 'friend@example.com' }); + expect(r.ok).toBe(true); + expect(r.role).toBe('operator'); + expect(r.isBootstrap).toBe(false); + }); + + test('isEmailAuthorized returns false after bootstrap for non-allowlisted', async () => { + await userStore.login({ email: 'admin@example.com' }); + expect(await userStore.isEmailAuthorized('random@example.com')).toBe(false); + await userStore.addToAllowlist('random@example.com'); + expect(await userStore.isEmailAuthorized('random@example.com')).toBe(true); + }); +}); + +describe('DC-048: email provider auth flow with userStore', () => { + let dir, userStore, EmailProvider; + beforeEach(() => { + dir = _tmpDir(); + userStore = require('../src/security/user-store').createUserStore({ dataDir: dir }); + EmailProvider = require('../src/auth/providers/email'); + }); + afterEach(() => _cleanup(dir)); + + function _makeProvider() { + // Real session stub — record create/setCookie calls without cookie IO. + const session = { + create: jest.fn(), + setCookie: jest.fn(), + isSessionValid: () => true, + getClientIP: (req) => req.ip || '127.0.0.1', + }; + const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; + const provider = new EmailProvider({ + config: { enabled: true, sessionDuration: '24h' }, + log, + session, + renewCSRFToken: () => 'csrf-token-stub', + siteConfig: {}, + userStore, + platformPaths: { dataDir: dir }, + }); + return { provider, session, log }; + } + + function _fakeReqRes({ body, query, ip, headers } = {}) { + const req = { + body: body || {}, + query: query || {}, + ip: ip || '127.0.0.1', + socket: { remoteAddress: ip || '127.0.0.1' }, + headers: headers || {}, + protocol: 'https', + secure: true, + }; + const res = { + _status: 200, + _body: null, + status(c) { this._status = c; return this; }, + json(b) { this._body = b; return this; }, + cookie: jest.fn(), + setHeader: jest.fn(), + getHeader: () => undefined, + }; + return { req, res }; + } + + test('initiate returns sent:true even for unauthorized email (enumeration prevention)', async () => { + const { provider } = _makeProvider(); + // Bootstrap first. + await userStore.login({ email: 'admin@x.com' }); + // Now an unauthorized user tries. + const { req, res } = _fakeReqRes({ body: { email: 'stranger@x.com' } }); + await provider.initiate('magic-link', req, res); + expect(res._body.sent).toBe(true); + }); + + test('verify rejects unauthorized email after bootstrap', async () => { + const { provider } = _makeProvider(); + await userStore.login({ email: 'admin@x.com' }); + // Issue token for an unauthorized user (provider's initiate still creates + // a token — the verify step is where authorization is enforced). + const initReq = _fakeReqRes({ body: { email: 'stranger@x.com' } }); + await provider.initiate('magic-link', initReq.req, initReq.res); + // The token was returned to the user as part of dev-console log. + // Grab the dev marker from the log mock to extract the URL → token. + const warnCalls = provider.deps.log.warn.mock.calls; + const marker = warnCalls.find(c => c[1] && c[1].includes('stranger@x.com')); + expect(marker).toBeTruthy(); + const urlMatch = marker[1].match(/url=(\S+)/); + expect(urlMatch).toBeTruthy(); + const url = new URL(urlMatch[1]); + const token = url.searchParams.get('token'); + + // Now verify — should reject. + const { req, res } = _fakeReqRes({ body: { token }, ip: '127.0.0.1' }); + await expect(provider.verify('verify-token', req, res)).rejects.toThrow(); + }); + + test('verify accepts authorized email + creates user record', async () => { + const { provider, session } = _makeProvider(); + await userStore.login({ email: 'admin@x.com' }); + await userStore.addToAllowlist('friend@x.com'); + + const initReq = _fakeReqRes({ body: { email: 'friend@x.com' } }); + await provider.initiate('magic-link', initReq.req, initReq.res); + const marker = provider.deps.log.warn.mock.calls + .find(c => c[1] && c[1].includes('friend@x.com')); + const url = new URL(marker[1].match(/url=(\S+)/)[1]); + const token = url.searchParams.get('token'); + + const { req, res } = _fakeReqRes({ body: { token } }); + await provider.verify('verify-token', req, res); + + // Session was created. + expect(session.create).toHaveBeenCalledTimes(1); + expect(session.setCookie).toHaveBeenCalledTimes(1); + + // User record exists. + const u = await userStore.getUserByEmail('friend@x.com'); + expect(u).toBeTruthy(); + expect(u.role).toBe('operator'); + + // req.user was tagged for audit attribution. + expect(req.user.id).toBe(u.id); + expect(req.user.role).toBe('operator'); + expect(req.user.isBootstrap).toBe(false); + + // Response includes user info. + expect(res._body.user.email).toBe('friend@x.com'); + expect(res._body.user.role).toBe('operator'); + }); + + test('verify rejects second use of same token (replay protection)', async () => { + const { provider } = _makeProvider(); + // Bootstrap. + const { req: bReq, res: bRes } = _fakeReqRes({ body: { email: 'admin@x.com' } }); + await provider.initiate('magic-link', bReq, bRes); + const marker = provider.deps.log.warn.mock.calls + .find(c => c[1] && c[1].includes('admin@x.com')); + const url = new URL(marker[1].match(/url=(\S+)/)[1]); + const token = url.searchParams.get('token'); + + // First verify succeeds. + const { req: v1Req, res: v1Res } = _fakeReqRes({ body: { token } }); + await provider.verify('verify-token', v1Req, v1Res); + expect(v1Res._body.message).toBe('Authenticated successfully'); + + // Second verify fails with generic message. + const { req: v2Req, res: v2Res } = _fakeReqRes({ body: { token } }); + await expect(provider.verify('verify-token', v2Req, v2Res)).rejects.toThrow(/invalid/); + }); +}); + +describe('DC-048: admin routes /me + /admin/users', () => { + let dir, userStore, adminRouter; + beforeEach(() => { + dir = _tmpDir(); + userStore = require('../src/security/user-store').createUserStore({ dataDir: dir }); + // Seed: bootstrap admin + userStore.login({ email: 'admin@x.com' }); + const initAdmin = require('../routes/auth/admin'); + adminRouter = initAdmin({ + asyncHandler: (fn) => fn, + errorResponse: (_res, code, msg) => { + const err = new Error(msg); err.statusCode = code; throw err; + }, + log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + session: null, + dataDir: dir, + }); + }); + afterEach(() => _cleanup(dir)); + + function _invoke(method, urlPath, { user } = {}) { + const req = { + method, + url: urlPath, + path: urlPath.split('?')[0], + query: {}, + body: {}, + headers: {}, + ip: '127.0.0.1', + params: {}, + user, + app: { locals: {} }, + }; + // Parse path into Express-style params + for (const layer of adminRouter.stack) { + if (layer.route && layer.route.methods[method.toLowerCase()]) { + const routePath = layer.route.path; + // Simple :param parsing for tests + const expectedParts = routePath.split('/').filter(Boolean); + const actualParts = req.path.split('/').filter(Boolean); + if (expectedParts.length !== actualParts.length) continue; + let match = true; + for (let i = 0; i < expectedParts.length; i++) { + if (expectedParts[i].startsWith(':')) { + req.params[expectedParts[i].slice(1)] = actualParts[i]; + } else if (expectedParts[i] !== actualParts[i]) { + match = false; break; + } + } + if (match) { + const res = { + _status: 200, + _body: null, + status(c) { this._status = c; return this; }, + json(b) { this._body = b; return this; }, + }; + // The router layer's .route.stack contains the middleware chain + // (e.g. _requireAdmin) + the actual handler. We walk the chain + // manually since we're bypassing Express. + const handlers = layer.route.stack.map(s => s.handle); + return { + layer, req, res, + run: async () => { + for (let i = 0; i < handlers.length; i++) { + const h = handlers[i]; + const isLast = i === handlers.length - 1; + const stepResult = await new Promise((resolveStep, rejectStep) => { + let nextCalled = false; + let nextErr = null; + const next = (err) => { + nextCalled = true; + nextErr = err || null; + resolveStep({ nextCalled, nextErr }); + }; + try { + const ret = h(req, res, next); + if (ret && typeof ret.then === 'function') { + ret.then(() => { + if (!nextCalled) resolveStep({ nextCalled, nextErr }); + }).catch(rejectStep); + } else if (!nextCalled) { + // Synchronous handler that didn't call next — assume it's the + // final handler that wrote to res. Resolve. + resolveStep({ nextCalled, nextErr }); + } + } catch (e) { rejectStep(e); } + }); + if (stepResult.nextErr) throw stepResult.nextErr; + if (!stepResult.nextCalled && !isLast) { + throw new Error('middleware chain did not call next'); + } + } + }, + }; + } + } + } + return null; + } + + test('/me returns admin user info when authenticated', async () => { + const admin = (await userStore.listUsers())[0]; + const r = _invoke('GET', '/me', { user: { id: admin.id, email: admin.email, role: 'admin' } }); + await r.run(); + expect(r.res._body.authenticated).toBe(true); + expect(r.res._body.role).toBe('admin'); + expect(r.res._body.user.email).toBe('admin@x.com'); + }); + + test('/me returns legacy:true when no user attributed', async () => { + const r = _invoke('GET', '/me', { user: null }); + await r.run(); + expect(r.res._body.legacy).toBe(true); + expect(r.res._body.role).toBe('admin'); // legacy compat + }); + + test('/admin/users requires admin role (403 for non-admin)', async () => { + const r = _invoke('GET', '/admin/users', { user: { id: 'fake', email: 'x@x.com', role: 'viewer' } }); + let caught = null; + try { await r.run(); } catch (e) { caught = e; } + expect(caught).toBeTruthy(); + expect(caught.statusCode).toBe(403); + }); + + test('/admin/users returns user list for admin', async () => { + const r = _invoke('GET', '/admin/users', { user: { id: 'admin-id', email: 'admin@x.com', role: 'admin' } }); + await r.run(); + expect(Array.isArray(r.res._body.users)).toBe(true); + expect(r.res._body.users).toHaveLength(1); + expect(r.res._body.users[0].email).toBe('admin@x.com'); + }); + + test('/admin/users POST adds to allowlist', async () => { + const r = _invoke('POST', '/admin/users', { + user: { id: 'admin-id', email: 'admin@x.com', role: 'admin' }, + }); + r.req.body = { email: 'newfriend@x.com' }; + await r.run(); + const allowlist = await userStore.listAllowlist(); + expect(allowlist).toContain('newfriend@x.com'); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/__tests__/invite-store.test.js b/dashcaddy-api/__tests__/invite-store.test.js new file mode 100644 index 0000000..1bb718e --- /dev/null +++ b/dashcaddy-api/__tests__/invite-store.test.js @@ -0,0 +1,191 @@ +/** + * Tests for invite-store (DC-048). + * Coverage: + * - issue returns raw token + id; token is 256-bit entropy + * - peek returns public-safe info without consuming + * - accept consumes + marks used, second accept returns already_used + * - expired token returns expired on accept + * - revoke removes by id + * - listOutstanding hides used/expired + * - peek returns null for unknown/used/expired (no enumeration) + * - token hash never leaves the store (only SHA-256 on disk) + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { createInviteStore, DEFAULT_TTL_MS } = require('../src/security/invite-store'); + +function _tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-invitetest-')); +} + +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +describe('invite-store: issue', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('issue returns raw token + id + email + role + expiresAt', async () => { + const r = await store.issue({ email: 'a@x.com', role: 'operator', ttlMs: 60_000 }); + expect(r.ok).toBe(true); + expect(r.id).toBeTruthy(); + expect(typeof r.token).toBe('string'); + expect(r.token.length).toBeGreaterThanOrEqual(40); + expect(r.email).toBe('a@x.com'); + expect(r.role).toBe('operator'); + expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now()); + }); + + test('token is base64url and has 256 bits of entropy', async () => { + const r = await store.issue({ email: 'a@x.com' }); + expect(r.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url + // 32 bytes encoded → 43 chars (no padding) + expect(r.token.length).toBeGreaterThanOrEqual(42); + expect(r.token.length).toBeLessThanOrEqual(44); + }); + + test('on-disk JSON contains hash, not raw token', async () => { + const r = await store.issue({ email: 'a@x.com' }); + const raw = fs.readFileSync(path.join(dir, 'invites.json'), 'utf8'); + expect(raw).not.toContain(r.token); // raw token never touches disk + // hash is 64 hex chars + expect(raw).toMatch(/[a-f0-9]{64}/); + }); + + test('two issues produce different tokens', async () => { + const r1 = await store.issue({ email: 'a@x.com' }); + const r2 = await store.issue({ email: 'b@x.com' }); + expect(r1.token).not.toEqual(r2.token); + }); + + test('invalid email rejected', async () => { + const r = await store.issue({ email: 'not-an-email' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid_email'); + }); +}); + +describe('invite-store: peek + accept', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('peek returns public-safe info', async () => { + const r = await store.issue({ email: 'a@x.com', role: 'operator' }); + const p = await store.peek(r.token); + expect(p).toBeTruthy(); + expect(p.email).toBe('a@x.com'); + expect(p.role).toBe('operator'); + expect(p.expiresAt).toBe(r.expiresAt); + }); + + test('peek does NOT consume the token', async () => { + const r = await store.issue({ email: 'a@x.com' }); + await store.peek(r.token); + await store.peek(r.token); + const accept = await store.accept(r.token); + expect(accept.ok).toBe(true); + }); + + test('peek returns null for unknown token', async () => { + const p = await store.peek('not-a-real-token'); + expect(p).toBe(null); + }); + + test('peek returns null for used token (no enumeration)', async () => { + const r = await store.issue({ email: 'a@x.com' }); + await store.accept(r.token); + const p = await store.peek(r.token); + expect(p).toBe(null); + }); + + test('peek returns null for expired token (no enumeration)', async () => { + const r = await store.issue({ email: 'a@x.com', ttlMs: 1 }); + await new Promise(res => setTimeout(res, 10)); + const p = await store.peek(r.token); + expect(p).toBe(null); + }); + + test('accept marks used + records accept time', async () => { + const r = await store.issue({ email: 'a@x.com' }); + const a = await store.accept(r.token, { acceptedBy: 'first@x.com' }); + expect(a.ok).toBe(true); + expect(a.invite.usedAt).toBeTruthy(); + expect(a.invite.email).toBe('a@x.com'); + }); + + test('accept returns already_used on second call', async () => { + const r = await store.issue({ email: 'a@x.com' }); + await store.accept(r.token); + const second = await store.accept(r.token); + expect(second.ok).toBe(false); + expect(second.reason).toBe('already_used'); + }); + + test('accept returns expired for TTL-passed token', async () => { + const r = await store.issue({ email: 'a@x.com', ttlMs: 1 }); + await new Promise(res => setTimeout(res, 10)); + const a = await store.accept(r.token); + expect(a.ok).toBe(false); + expect(a.reason).toBe('expired'); + }); + + test('accept returns not_found for unknown token', async () => { + const a = await store.accept('not-real'); + expect(a.ok).toBe(false); + expect(a.reason).toBe('not_found'); + }); +}); + +describe('invite-store: revoke + listOutstanding', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createInviteStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('revoke removes an invite', async () => { + const r = await store.issue({ email: 'a@x.com' }); + const rev = await store.revoke(r.id); + expect(rev.ok).toBe(true); + const peek = await store.peek(r.token); + expect(peek).toBe(null); + }); + + test('revoke returns not_found for unknown id', async () => { + const r = await store.revoke('not-an-id'); + expect(r.ok).toBe(false); + expect(r.reason).toBe('not_found'); + }); + + test('listOutstanding excludes used + expired', async () => { + const r1 = await store.issue({ email: 'a@x.com', ttlMs: 60_000 }); + const r2 = await store.issue({ email: 'b@x.com', ttlMs: 60_000 }); + const r3 = await store.issue({ email: 'c@x.com', ttlMs: 1 }); + await store.accept(r1.token); // used + await new Promise(res => setTimeout(res, 10)); // expire r3 + + const list = await store.listOutstanding(); + expect(list).toHaveLength(1); + expect(list[0].id).toBe(r2.id); + expect(list[0].email).toBe('b@x.com'); + }); + + test('listOutstanding sorted by expiresAt', async () => { + const early = await store.issue({ email: 'a@x.com', ttlMs: 1000 }); + const late = await store.issue({ email: 'b@x.com', ttlMs: 60_000 }); + const list = await store.listOutstanding(); + expect(list[0].id).toBe(early.id); + expect(list[1].id).toBe(late.id); + }); +}); + +describe('invite-store: DEFAULT_TTL_MS', () => { + test('default is 24 hours', () => { + expect(DEFAULT_TTL_MS).toBe(24 * 60 * 60 * 1000); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/__tests__/public-routes-drift.test.js b/dashcaddy-api/__tests__/public-routes-drift.test.js index bd88e31..81a21c6 100644 --- a/dashcaddy-api/__tests__/public-routes-drift.test.js +++ b/dashcaddy-api/__tests__/public-routes-drift.test.js @@ -288,9 +288,20 @@ describe('Public-routes allowlist drift (prevents DC-012-style dead entries)', ( describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => { test('every PUBLIC_ROUTES entry matches an actual mounted route', () => { + // DC-048: invite routes are only mounted when the operator has + // enabled email auth (siteConfig.authProviders.email.enabled === true). + // The aggregator factory gates this on a non-proxied config flag, so + // the router walker in this test (which runs with stub deps) doesn't + // see them mounted. They're not stale — they're conditional. Same + // for any future provider-conditional mount. + const conditionalMounts = new Set([ + '/api/v1/auth/invites/:token', + '/api/v1/auth/invites/:token/accept', + ]); const stale = []; for (const entry of publicRoutes) { if (entry.endsWith('/')) continue; // prefix matches, skip + if (conditionalMounts.has(entry)) continue; // gated by config flag if (!mountedRoutes.has(entry)) stale.push(entry); } expect(stale).toEqual([]); diff --git a/dashcaddy-api/__tests__/user-store.test.js b/dashcaddy-api/__tests__/user-store.test.js new file mode 100644 index 0000000..c656129 --- /dev/null +++ b/dashcaddy-api/__tests__/user-store.test.js @@ -0,0 +1,231 @@ +/** + * Tests for user-store (DC-048). + * Coverage: + * - bootstrap rule: first user becomes admin + * - allowlist enforcement: emails not on the list are rejected + * - login idempotency: existing user just bumps counters + * - role updates with valid/invalid roles + * - last-admin protection: cannot delete the only admin + * - concurrent login safety: mutex serializes + * - file persistence: writes are atomic and survive process kill + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { createUserStore, ROLES, VALID_ROLES } = require('../src/security/user-store'); + +function _tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-usertest-')); +} + +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +describe('user-store: bootstrap', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('first login becomes admin (isBootstrap=true)', async () => { + const r = await store.login({ email: 'alice@example.com', ip: '127.0.0.1' }); + expect(r.ok).toBe(true); + expect(r.isBootstrap).toBe(true); + expect(r.role).toBe('admin'); + expect(r.user.email).toBe('alice@example.com'); + expect(r.user.id).toBeTruthy(); + expect(r.user.loginCount).toBe(1); + }); + + test('bootstrap sentinel written', async () => { + await store.login({ email: 'a@x.com' }); + expect(fs.existsSync(path.join(dir, '.bootstrapped'))).toBe(true); + const sentinel = JSON.parse(fs.readFileSync(path.join(dir, '.bootstrapped'), 'utf8')); + expect(sentinel.adminEmail).toBe('a@x.com'); + }); + + test('bootstrap-admin email is added to allowlist', async () => { + await store.login({ email: 'first@x.com' }); + const allowlist = await store.listAllowlist(); + expect(allowlist).toContain('first@x.com'); + }); + + test('second login denied without allowlist', async () => { + await store.login({ email: 'first@x.com' }); + const r = await store.login({ email: 'second@x.com' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('not_authorized'); + }); + + test('second login allowed if email is on allowlist', async () => { + await store.login({ email: 'first@x.com' }); + await store.addToAllowlist('friend@x.com'); + const r = await store.login({ email: 'friend@x.com' }); + expect(r.ok).toBe(true); + expect(r.isBootstrap).toBe(false); + expect(r.role).toBe('operator'); // not admin — bootstrap already happened + }); + + test('replay bootstrap after delete restores allow-everyone', async () => { + await store.login({ email: 'first@x.com' }); + // Cannot fully replay — bootstrap sentinel persists. Verify the + // invariant: once bootstrapped, even an empty allowlist rejects new + // emails unless added explicitly. + const r = await store.login({ email: 'random@x.com' }); + expect(r.ok).toBe(false); + }); +}); + +describe('user-store: login idempotency', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('existing user login bumps counters, does NOT bootstrap again', async () => { + const r1 = await store.login({ email: 'a@x.com' }); + const id = r1.user.id; + const r2 = await store.login({ email: 'a@x.com', ip: '10.0.0.1' }); + expect(r2.ok).toBe(true); + expect(r2.isBootstrap).toBe(false); + expect(r2.user.id).toBe(id); + expect(r2.user.loginCount).toBe(2); + expect(r2.user.lastLoginIp).toBe('10.0.0.1'); + }); + + test('email normalized to lowercase', async () => { + await store.login({ email: 'Alice@Example.COM' }); + const users = await store.listUsers(); + expect(users).toHaveLength(1); + expect(users[0].email).toBe('alice@example.com'); + }); +}); + +describe('user-store: validation', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('invalid email rejected', async () => { + const r = await store.login({ email: 'not-an-email' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid_email'); + }); + + test('empty email rejected', async () => { + const r = await store.login({ email: '' }); + expect(r.ok).toBe(false); + }); + + test('isEmailAuthorized returns true only when allowlist or bootstrap-pending', async () => { + expect(await store.isEmailAuthorized('anyone@x.com')).toBe(true); // bootstrap pending + await store.login({ email: 'first@x.com' }); + expect(await store.isEmailAuthorized('anyone@x.com')).toBe(false); + await store.addToAllowlist('friend@x.com'); + expect(await store.isEmailAuthorized('friend@x.com')).toBe(true); + expect(await store.isEmailAuthorized('stranger@x.com')).toBe(false); + }); +}); + +describe('user-store: roles + delete', () => { + let dir, store; + beforeEach(() => { + dir = _tmpDir(); + store = createUserStore({ dataDir: dir }); + }); + afterEach(() => _cleanup(dir)); + + test('setRole updates an existing user', async () => { + await store.login({ email: 'a@x.com' }); + await store.addToAllowlist('b@x.com'); + const r = await store.login({ email: 'b@x.com' }); + const set = await store.setRole(r.user.id, 'viewer'); + expect(set.ok).toBe(true); + const got = await store.getUser(r.user.id); + expect(got.role).toBe('viewer'); + }); + + test('setRole rejects invalid role', async () => { + await store.login({ email: 'a@x.com' }); + const set = await store.setRole('nonexistent', 'superuser'); + expect(set.ok).toBe(false); + expect(set.reason).toBe('invalid_role'); + }); + + test('deleteUser removes user + allowlist entry', async () => { + await store.login({ email: 'a@x.com' }); + await store.addToAllowlist('b@x.com'); + const r = await store.login({ email: 'b@x.com' }); + const del = await store.deleteUser(r.user.id); + expect(del.ok).toBe(true); + const users = await store.listUsers(); + expect(users).toHaveLength(1); // only the admin + const allowlist = await store.listAllowlist(); + expect(allowlist).not.toContain('b@x.com'); + }); + + test('deleteUser refuses to delete the last admin', async () => { + const r = await store.login({ email: 'admin@x.com' }); + const del = await store.deleteUser(r.user.id); + expect(del.ok).toBe(false); + expect(del.reason).toBe('last_admin'); + }); + + test('deleteUser allows removing admin when another admin exists', async () => { + await store.login({ email: 'admin1@x.com' }); + await store.addToAllowlist('admin2@x.com'); + const r2 = await store.login({ email: 'admin2@x.com' }); + await store.setRole(r2.user.id, 'admin'); + const r1 = await store.listUsers(); + const admin1 = r1.find(u => u.email === 'admin1@x.com'); + const del = await store.deleteUser(admin1.id); + expect(del.ok).toBe(true); + const remaining = await store.listUsers(); + expect(remaining).toHaveLength(1); + expect(remaining[0].role).toBe('admin'); + }); +}); + +describe('user-store: atomic writes', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createUserStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('users.json is well-formed after write', async () => { + await store.login({ email: 'a@x.com' }); + const raw = fs.readFileSync(path.join(dir, 'users.json'), 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed.users).toBeTruthy(); + expect(parsed.order).toHaveLength(1); + }); + + test('corrupt users.json falls back to empty (no crash)', async () => { + fs.writeFileSync(path.join(dir, 'users.json'), '{not json'); + const users = await store.listUsers(); + expect(users).toEqual([]); + }); + + test('listUsers returns most-recent-first by createdAt order', async () => { + await store.login({ email: 'a@x.com' }); + await new Promise(r => setTimeout(r, 5)); + await store.addToAllowlist('b@x.com'); + await store.login({ email: 'b@x.com' }); + const users = await store.listUsers(); + expect(users[0].email).toBe('b@x.com'); + expect(users[1].email).toBe('a@x.com'); + }); +}); + +describe('user-store: ROLES constants', () => { + test('exports admin/operator/viewer roles', () => { + expect(ROLES.ADMIN).toBe('admin'); + expect(ROLES.OPERATOR).toBe('operator'); + expect(ROLES.VIEWER).toBe('viewer'); + expect(VALID_ROLES.has('admin')).toBe(true); + expect(VALID_ROLES.has('operator')).toBe(true); + expect(VALID_ROLES.has('viewer')).toBe(true); + expect(VALID_ROLES.has('superuser')).toBe(false); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/routes/auth/admin.js b/dashcaddy-api/routes/auth/admin.js new file mode 100644 index 0000000..3515397 --- /dev/null +++ b/dashcaddy-api/routes/auth/admin.js @@ -0,0 +1,341 @@ +/** + * Admin + me routes — DC-048. + * + * Mounted at /api/v1/auth. All `/admin/*` routes require the session to + * belong to a user with role 'admin'. `/me` requires any authenticated session. + * + * Endpoints: + * GET /me — current user (id, email, role, isAdmin) + * GET /admin/users — list all users + * POST /admin/users — pre-authorize an email (allowlist) + * PATCH /admin/users/:id — change a user's role + * DELETE /admin/users/:id — delete user + remove from allowlist + * GET /admin/allowlist — list authorized emails + * GET /admin/invites — list outstanding invites + * POST /admin/invites — issue a new invite (returns raw token ONCE) + * DELETE /admin/invites/:id — revoke an invite + * + * POST /invites/accept — PUBLIC — redeem an invite token, + * create user, set session cookie + * GET /invites/:token — PUBLIC — peek at an invite (email, + * role, expires) without consuming it. + */ + +'use strict'; + +const express = require('express'); +const path = require('path'); +const platformPaths = require('../../platform-paths'); +const { createUserStore } = require('../../src/security/user-store'); +const { createInviteStore } = require('../../src/security/invite-store'); +const emailSender = require('../../src/auth/providers/email-sender'); +const { ValidationError, NotFoundError, ForbiddenError } = require('../../src/utilities/errors'); +const { ok, successMessage } = require('../../src/utils/responses'); + +/** + * Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's + * _resolvePublicUrl logic — kept duplicated (not extracted) because the two + * callers have slightly different link paths and the duplication is smaller + * than the abstraction would be. + */ +function _buildInviteUrl(req, siteConfig, token) { + if (siteConfig && siteConfig.publicBaseUrl) { + return siteConfig.publicBaseUrl.replace(/\/+$/, '') + + '/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept'; + } + const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https'); + const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host)) + || (siteConfig && siteConfig.dashboardHost) || 'localhost:3001'; + return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`; +} + +function _requireAdmin(req, _res, next) { + if (!req.user || req.user.role !== 'admin') { + return next(new ForbiddenError('Admin role required')); + } + next(); +} + +function _buildEmailText({ acceptUrl, ttlHours, role }) { + return [ + 'Hi,', + '', + 'You\'ve been invited to join a DashCaddy instance as a ' + role + '.', + 'Click the link below within ' + ttlHours + ' hours to accept:', + '', + acceptUrl, + '', + 'This link is single-use. If you weren\'t expecting this invitation,', + 'you can safely ignore this email.', + '', + '— DashCaddy', + ].join('\n'); +} + +function _buildEmailHtml({ acceptUrl, ttlHours, role }) { + return [ + '', + '

You\'re invited to DashCaddy

', + '

You\'ve been invited to join as ' + role + '.

', + '

Click the button below within ' + ttlHours + ' hours to accept:

', + '

Accept invitation

', + '

If the button doesn\'t work, paste this link into your browser:
' + acceptUrl + '

', + '

If you weren\'t expecting this, you can ignore this email.

', + '', + ].join('\n'); +} + +module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }) { + const router = express.Router(); + // user-store / invite-store handle their own defensive dataDir resolution + // (they ignore Proxy/function values from universal-deps test deps). + const resolvedDataDir = dataDir || (platformPaths && platformPaths.dataDir); + const userStore = createUserStore({ dataDir: resolvedDataDir, log }); + const inviteStore = createInviteStore({ dataDir: resolvedDataDir, log }); + + // ── /me ─────────────────────────────────────────────────────────────── + + router.get('/me', asyncHandler(async (req, res) => { + if (!req.user || !req.user.id) { + // Legacy session without user attribution. Return the bare role + // (defaults to admin for backwards-compat) but signal via + // `legacy: true` so the UI knows. + return ok(res, { + user: null, + authenticated: session ? session.isSessionValid(req) : false, + role: 'admin', // legacy: assume operator-level access + legacy: true, + }); + } + const stored = await userStore.getUser(req.user.id); + return ok(res, { + user: stored + ? { + id: stored.id, + email: stored.email, + displayName: stored.displayName, + role: stored.role, + isAdmin: stored.role === 'admin', + createdAt: stored.createdAt, + lastLoginAt: stored.lastLoginAt, + loginCount: stored.loginCount, + } + : null, + authenticated: true, + role: req.user.role, + legacy: false, + }); + }, 'auth-me')); + + // ── /admin/users ────────────────────────────────────────────────────── + + router.get('/admin/users', _requireAdmin, asyncHandler(async (_req, res) => { + const users = await userStore.listUsers(); + return ok(res, { users }); + }, 'auth-admin-users-list')); + + router.post('/admin/users', _requireAdmin, asyncHandler(async (req, res) => { + const { email, role } = req.body || {}; + if (!email) throw new ValidationError('email is required', 'email'); + if (role && !userStore.VALID_ROLES.has(role)) { + throw new ValidationError('Invalid role', 'role'); + } + const result = await userStore.addToAllowlist(email); + if (!result.ok) throw new ValidationError(result.reason, 'email'); + // If a role was provided AND the user already exists, also set the role. + if (role) { + const existing = await userStore.getUserByEmail(email); + if (existing) { + await userStore.setRole(existing.id, role); + } + } + return ok(res, { + email: email.toLowerCase(), + alreadyExisted: result.alreadyExisted, + }); + }, 'auth-admin-users-create')); + + router.patch('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => { + const { role } = req.body || {}; + if (!role || !userStore.VALID_ROLES.has(role)) { + throw new ValidationError('Invalid role', 'role'); + } + const result = await userStore.setRole(req.params.id, role); + if (!result.ok) { + throw result.reason === 'not_found' + ? new NotFoundError('User not found') + : new ValidationError(result.reason, 'role'); + } + return successMessage(res, 'Role updated'); + }, 'auth-admin-users-update')); + + router.delete('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => { + const result = await userStore.deleteUser(req.params.id); + if (!result.ok) { + if (result.reason === 'not_found') throw new NotFoundError('User not found'); + if (result.reason === 'last_admin') { + throw new ValidationError('Cannot delete the last admin'); + } + throw new ValidationError(result.reason); + } + return successMessage(res, 'User deleted'); + }, 'auth-admin-users-delete')); + + // ── /admin/allowlist ────────────────────────────────────────────────── + + router.get('/admin/allowlist', _requireAdmin, asyncHandler(async (_req, res) => { + const emails = await userStore.listAllowlist(); + return ok(res, { emails }); + }, 'auth-admin-allowlist')); + + // ── /admin/invites ──────────────────────────────────────────────────── + + router.get('/admin/invites', _requireAdmin, asyncHandler(async (_req, res) => { + const invites = await inviteStore.listOutstanding(); + return ok(res, { invites }); + }, 'auth-admin-invites-list')); + + router.post('/admin/invites', _requireAdmin, asyncHandler(async (req, res) => { + const { email, role, ttlHours, sendEmail } = req.body || {}; + if (!email) throw new ValidationError('email is required', 'email'); + const ttlMs = (typeof ttlHours === 'number' && ttlHours > 0 && ttlHours <= 168) + ? ttlHours * 60 * 60 * 1000 + : inviteStore.DEFAULT_TTL_MS; + const invitedBy = (req.user && req.user.email) || 'admin'; + const issued = await inviteStore.issue({ + email, + role: (role && userStore.VALID_ROLES.has(role)) ? role : 'operator', + ttlMs, + invitedBy, + }); + if (!issued.ok) throw new ValidationError(issued.reason, 'email'); + + let deliveredVia = 'none'; + let maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2'); + if (sendEmail !== false) { + // Best-effort send. If SMTP isn't configured, log to error.log (dev path). + const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token); + const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000)); + const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role }); + const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role }); + try { + const smtpConfig = req.app.locals && req.app.locals.emailConfig; + if (smtpConfig && emailSender.isConfigured(smtpConfig)) { + await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html); + deliveredVia = 'email'; + } else { + // Dev fallback — log the raw link so operators can grab it. + log.warn && log.warn('auth-invite-dev', + '[DC-048-DEV-INVITE-LINK] email=' + issued.email + + ' role=' + issued.role + ' url=' + acceptUrl); + deliveredVia = 'dev-console'; + } + } catch (sendErr) { + log.warn && log.warn('auth-invite-send', + 'invite send failed: ' + (sendErr.message || String(sendErr))); + deliveredVia = 'failed'; + } + } else { + deliveredVia = 'manual'; + } + + return ok(res, { + id: issued.id, + email: issued.email, + role: issued.role, + expiresAt: issued.expiresAt, + // The raw token is returned ONCE so the admin UI can show/copy the + // link. It is also embedded in the email when sendEmail !== false. + acceptUrl: (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl + ? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '') + : ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' + + (req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))) + + '/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept', + deliveredVia, + maskedEmail, + }); + }, 'auth-admin-invites-create')); + + router.delete('/admin/invites/:id', _requireAdmin, asyncHandler(async (req, res) => { + const result = await inviteStore.revoke(req.params.id); + if (!result.ok) throw new NotFoundError('Invite not found'); + return successMessage(res, 'Invite revoked'); + }, 'auth-admin-invites-revoke')); + + // ── /invites (public) ────────────────────────────────────────────────── + + // PUBLIC: peek at an invite without consuming it. + router.get('/invites/:token', asyncHandler(async (req, res) => { + const peeked = await inviteStore.peek(req.params.token); + if (!peeked) { + // Same response as "not found" — don't leak token state. + return ok(res, { valid: false }); + } + return ok(res, { + valid: true, + email: peeked.email, + role: peeked.role, + expiresAt: peeked.expiresAt, + }); + }, 'auth-invites-peek')); + + // PUBLIC: accept an invite token. Creates the user, sets the session. + router.post('/invites/:token/accept', asyncHandler(async (req, res) => { + const result = await inviteStore.accept(req.params.token, { + acceptedBy: req.user ? req.user.email : null, + }); + if (!result.ok) { + throw new ValidationError('Invitation is ' + result.reason.replace('_', ' '), 'token'); + } + + // Authorize the email + create the user record. + const invite = result.invite; + const userResult = await userStore.login({ + email: invite.email, + ip: req.ip || '', + displayName: invite.email.split('@')[0], + createdBy: 'invite:' + invite.id, + }); + if (!userResult.ok) { + throw new ValidationError('Could not create user from invite: ' + userResult.reason); + } + + // Create session (same shape as email verify path). + if (session) { + session.create(req, '24h'); + session.setCookie(res, '24h'); + } + if (req.app.locals && req.app.locals.renewCSRFToken) { + req.app.locals.renewCSRFToken(res, req.secure || req.protocol === 'https'); + } + + // Attach user to request for audit log. + req.user = { + id: userResult.user.id, + email: userResult.user.email, + role: userResult.user.role, + isAdmin: userResult.user.role === 'admin', + isBootstrap: false, + viaProvider: 'invite', + }; + + log.info && log.info('auth', 'invite accepted, user created', { + userId: userResult.user.id, + email: userResult.user.email, + role: userResult.user.role, + inviteId: invite.id, + }); + + return ok(res, { + message: 'Invitation accepted', + user: { + id: userResult.user.id, + email: userResult.user.email, + role: userResult.user.role, + }, + csrfToken: res.locals && res.locals.csrfToken, + }); + }, 'auth-invites-accept')); + + return router; +}; \ No newline at end of file diff --git a/dashcaddy-api/routes/auth/index.js b/dashcaddy-api/routes/auth/index.js index 98c8d61..3d91c0c 100644 --- a/dashcaddy-api/routes/auth/index.js +++ b/dashcaddy-api/routes/auth/index.js @@ -4,7 +4,9 @@ const initKeys = require('./keys'); const initSessionHandlers = require('./session-handlers'); const initSsoGate = require('./sso-gate'); const initLogin = require('./login'); +const initAdmin = require('./admin'); const { createAuthProviderRegistry } = require('../../src/auth/providers'); +const { createUserStore } = require('../../src/security/user-store'); /** * Auth routes aggregator @@ -39,6 +41,32 @@ function _extractEmailConfig(ctx) { module.exports = function(ctx) { const router = express.Router(); + // DC-048: opt-in user store. Only instantiated when the operator has + // explicitly enabled email auth in siteConfig. The default for new + // installs is "no user-store, no allowlist, no admin invites" — the + // legacy single-user TOTP flow. Operators who turn email auth on + // (siteConfig.authProviders.email.enabled = true) opt into multi-user. + // Once opted in, the first email to log in is the bootstrap admin. + const platformPaths = ctx.platformPaths || require('../../platform-paths'); + let userStore = null; + + const _emailExplicitlyEnabled = + ctx.siteConfig && + ctx.siteConfig.authProviders && + ctx.siteConfig.authProviders.email && + ctx.siteConfig.authProviders.email.enabled === true; + + if (_emailExplicitlyEnabled) { + userStore = createUserStore({ + dataDir: platformPaths.dataDir, + log: ctx.log, + }); + ctx.userStore = userStore; + ctx.log && ctx.log.info && ctx.log.info('user', 'multi-user mode enabled (email auth on)'); + } else { + ctx.log && ctx.log.info && ctx.log.info('user', 'single-user mode (email auth not enabled — set siteConfig.authProviders.email.enabled = true to opt into multi-user)'); + } + // Extract dependencies from context const deps = { authManager: ctx.authManager, @@ -62,7 +90,11 @@ module.exports = function(ctx) { notificationManager: ctx.notification, siteConfig: ctx.siteConfig, // DC-047: data-directory resolution for the email-token JSON store. - platformPaths: ctx.platformPaths || null, + platformPaths, + // DC-048: user store for allowlist + bootstrap. Null when email + // auth is disabled — providers fall back to "allow everyone" legacy + // behavior (DC-046/047 semantics). + userStore, }; const { getAppSession, appSessionCache } = initSessionHandlers(deps); @@ -75,7 +107,7 @@ module.exports = function(ctx) { credentialManager: ctx.credentialManager, session: ctx.session, saveTotpConfig: ctx.saveTotpConfig, - config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: true } }, + config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: false } }, log: ctx.log, renewCSRFToken: ctx.middlewareResult?.renewCSRFToken, // DC-047: EmailMagicLinkProvider needs SMTP config + a public URL @@ -84,6 +116,9 @@ module.exports = function(ctx) { emailConfig: _extractEmailConfig(ctx), siteConfig: ctx.siteConfig || {}, platformPaths: deps.platformPaths, + // DC-048: user store shared by every provider for allowlist checks + // and the bootstrap-admin-on-first-login rule. + userStore: deps.userStore, }, ctx.siteConfig ); @@ -106,5 +141,18 @@ module.exports = function(ctx) { router.use(initKeys(deps)); router.use(initSsoGate({ ...deps, getAppSession, appSessionCache })); + // DC-048: mount admin routes ONLY when the user-store was instantiated + // (i.e. email auth is enabled). Single-user installs don't see /me, + // /admin/*, or /invites/* at all. The route paths simply don't exist + // so a request to /api/v1/auth/me returns 404 from the apiRouter. + if (userStore) { + router.use('/auth', initAdmin({ + asyncHandler: ctx.asyncHandler, + errorResponse: ctx.errorResponse, + log: ctx.log, + session: ctx.session, + })); + } + return router; }; diff --git a/dashcaddy-api/src/auth/providers/email.js b/dashcaddy-api/src/auth/providers/email.js index 92a1194..e118b86 100644 --- a/dashcaddy-api/src/auth/providers/email.js +++ b/dashcaddy-api/src/auth/providers/email.js @@ -81,8 +81,9 @@ class EmailMagicLinkProvider extends AuthProvider { this.store.startPruneTimer(); this.emailConfig = deps.emailConfig || null; - // DC-048 will replace this with the real authorized-users check. - this.authorizedEmails = deps.authorizedEmails || (() => true); + // DC-048: real authorized-users check via the user store. Falls back + // to "allow everyone" if no store is wired (dev/legacy installs). + this.userStore = deps.userStore || null; // Public URL templates — overridable for testing. this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS; this.maxBodyLength = 32_000; @@ -131,10 +132,12 @@ class EmailMagicLinkProvider extends AuthProvider { */ _isProviderEnabled() { const flag = this.deps.config && this.deps.config.enabled; - // Default to TRUE: dev installs should "just work". Operators who want - // to disable email login set `siteConfig.authProviders.email.enabled = false` - // — the same config knob the TOTP provider uses. - if (flag === false) return false; + // Default to FALSE (DC-048 opt-in): operators must explicitly enable + // email auth via `siteConfig.authProviders.email.enabled = true`. Until + // then, the email login methods endpoint reports the provider as + // disabled and the auth UI doesn't render the email button. TOTP-only + // installs see no behavior change. + if (flag !== true) return false; return true; } @@ -277,12 +280,64 @@ class EmailMagicLinkProvider extends AuthProvider { throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used'); } + // DC-048: authorization gate. If the email isn't on the allowlist and + // bootstrap has already happened, reject. The token is still consumed + // so the same generic message is returned for "valid token but you're + // not allowed" — prevents a side-channel that distinguishes + // "token worked but you're banned" from "token didn't exist". + // + // NOTE: the DC-047 design comment claimed "initiate" would also silently + // drop unauthorized emails. That was aspirational; the real enumeration + // prevention lives at verify-time (here). Initiate-time, we still issue + // tokens and return success — so an unauthorized user thinks the link + // works, but it rejects at click-time. Same as DC-047 claimed; we just + // moved the check from initiate to verify where it can actually run. + if (this.userStore) { + const allowed = await this.userStore.isEmailAuthorized(record.email); + if (!allowed) { + // Audit the denial. + this.deps.log && this.deps.log.warn && this.deps.log.warn('auth', 'email magic link rejected — not authorized', { + email: record.email, + ip: this._clientIP(req), + }); + // Mark token used so a stolen token can't be replayed by a legit user later. + await this.store.markUsed(record.hash).catch(() => {}); + throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used'); + } + } + // Side-effect logging (NOT info-disclosure — just that a token was used). this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', { email: record.email, ip: this._clientIP(req), }); + // DC-048: record-or-create the user. First login → bootstrap admin. + // After that → must be on allowlist (already checked above). + let userRecord = null; + let isBootstrap = false; + if (this.userStore) { + const result = await this.userStore.login({ + email: record.email, + ip: this._clientIP(req), + }); + if (!result.ok) { + // Shouldn't reach here — isEmailAuthorized just passed — but + // handle the edge case where allowlist was mutated between calls. + await this.store.markUsed(record.hash).catch(() => {}); + throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used'); + } + userRecord = result.user; + isBootstrap = result.isBootstrap; + if (this.deps.log && this.deps.log.info) { + this.deps.log.info('auth', isBootstrap ? 'bootstrap admin first login' : 'user login', { + userId: userRecord.id, + email: userRecord.email, + role: userRecord.role, + }); + } + } + // Create the session + cookie. Same shape as TOTP's verify path. this.deps.session.create(req, this.deps.config && this.deps.config.sessionDuration || '24h'); this.deps.session.setCookie(res, this.deps.config && this.deps.config.sessionDuration || '24h'); @@ -290,11 +345,32 @@ class EmailMagicLinkProvider extends AuthProvider { ? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https') : undefined; + // DC-048: tag the request with the authenticated user so downstream + // middleware + audit log can attribute the session. We mutate req so + // the audit logger (which runs as response middleware) sees it. + if (userRecord) { + req.user = { + id: userRecord.id, + email: userRecord.email, + role: userRecord.role, + isAdmin: userRecord.role === 'admin', + isBootstrap, + }; + } + return ok(res, { message: 'Authenticated successfully', method: 'email', email: AuthProvider.maskEmail(record.email), csrfToken: newCsrf, + user: userRecord + ? { + id: userRecord.id, + email: userRecord.email, + role: userRecord.role, + isBootstrap, + } + : null, }); } diff --git a/dashcaddy-api/src/auth/providers/index.js b/dashcaddy-api/src/auth/providers/index.js index 3d2fa2a..4be9f4f 100644 --- a/dashcaddy-api/src/auth/providers/index.js +++ b/dashcaddy-api/src/auth/providers/index.js @@ -42,6 +42,8 @@ function createAuthProviderRegistry(deps, config) { ...deps, config: deps.config.totp, // the existing totpConfig object from app.js saveProviderConfig: deps.saveTotpConfig, // existing helper + // DC-048: user store for bootstrap + audit attribution on TOTP logins. + userStore: deps.userStore || null, }); providers.set('totp', totpProvider); @@ -56,9 +58,9 @@ function createAuthProviderRegistry(deps, config) { saveProviderConfig: deps.saveProviderConfig || (async () => {}), emailConfig: deps.emailConfig || null, siteConfig: deps.siteConfig || {}, - // DC-048 hook: today every email is authorized. Once multi-user ships, - // this is replaced with a real allowlist check. - authorizedEmails: deps.authorizedEmails || (() => true), + // DC-048: real authorization check via the user store. Without it, + // every email is allowed (legacy single-user behavior). + userStore: deps.userStore || null, })); // Future: OIDC, SAML, passkeys — each gated on config.authProviders diff --git a/dashcaddy-api/src/auth/providers/totp.js b/dashcaddy-api/src/auth/providers/totp.js index 25c6c0e..53b32b1 100644 --- a/dashcaddy-api/src/auth/providers/totp.js +++ b/dashcaddy-api/src/auth/providers/totp.js @@ -274,6 +274,51 @@ class TotpProvider extends AuthProvider { this.deps.session.create(req, this.deps.config.sessionDuration); this.deps.session.setCookie(res, this.deps.config.sessionDuration); + // DC-048: bootstrap-on-first-TOTP-verify. If no user store is wired + // (legacy install), skip silently — operator keeps anonymous access. + // If a user store IS wired and bootstrap hasn't happened yet, create + // a "system-admin" record tied to this TOTP login so the operator + // shows up in /api/v1/auth/admin/users. Email is null because TOTP + // has no email to attribute. + if (this.deps.userStore) { + const isBootstrapped = await this.deps.userStore.isBootstrapComplete(); + if (!isBootstrapped) { + const result = await this.deps.userStore.login({ + email: 'system@totp.local', + ip: this._clientIP(req), + displayName: 'Operator (TOTP)', + }); + if (result.ok) { + req.user = { + id: result.user.id, + email: null, + role: result.user.role, + isAdmin: result.user.role === 'admin', + isBootstrap: result.isBootstrap, + viaProvider: 'totp', + }; + this.deps.log.info('auth', 'system admin bootstrapped via TOTP', { + userId: result.user.id, + role: result.user.role, + }); + } + } else { + // Bootstrap already happened — find the system-admin record and + // attach it to this session for audit-log attribution. + const sys = await this.deps.userStore.getUserByEmail('system@totp.local'); + if (sys) { + req.user = { + id: sys.id, + email: null, + role: sys.role, + isAdmin: sys.role === 'admin', + isBootstrap: false, + viaProvider: 'totp', + }; + } + } + } + const newCsrfToken = this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https'); this.deps.log.debug('auth', 'Session created', { sessions: this.deps.session.ipSessions.size }); diff --git a/dashcaddy-api/src/security/audit-logger.js b/dashcaddy-api/src/security/audit-logger.js index 99e5f32..17ae814 100644 --- a/dashcaddy-api/src/security/audit-logger.js +++ b/dashcaddy-api/src/security/audit-logger.js @@ -231,6 +231,18 @@ class AuditLogger { details.body = safe; } + // DC-048: attribute the audit entry to the authenticated user when + // a session belongs to a known user record. Tag with id + role + + // email (or null for the TOTP-attributed "system" operator). When + // req.user is absent (legacy session, no auth), omit the fields + // entirely so existing log readers don't break. + if (req.user && req.user.id) { + details.userId = req.user.id; + details.userRole = req.user.role || null; + if (req.user.email) details.userEmail = req.user.email; + if (req.user.viaProvider) details.viaProvider = req.user.viaProvider; + } + this.log({ action, resource, details, outcome, ip }).catch(() => {}); return originalJson(data); diff --git a/dashcaddy-api/src/security/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js index 7803ef9..0959052 100644 --- a/dashcaddy-api/src/security/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -153,6 +153,10 @@ function csrfValidationMiddleware(req, res, next) { '/api/v1/auth/login/:provider/verify', '/api/v1/auth/login/:provider/initiate', '/api/v1/auth/disable/:provider', + // DC-048: invite redemption is the same exemption as login verify — + // the user has no session cookie yet (they just clicked an email link). + // CSRF on this boundary is enforced by SameSite=Lax instead. + '/api/v1/auth/invites/:token/accept', '/health', '/health/live', '/health/ready', diff --git a/dashcaddy-api/src/security/invite-store.js b/dashcaddy-api/src/security/invite-store.js new file mode 100644 index 0000000..b4d161d --- /dev/null +++ b/dashcaddy-api/src/security/invite-store.js @@ -0,0 +1,266 @@ +/** + * Invite store — DC-048. + * + * Single-use invite tokens with TTL. Admin generates an invite for an email; + * the system emails (or logs in dev) a magic-link-style URL containing the + * raw token. The recipient clicks → accepts → becomes an authorized user. + * + * Storage: data/invites.json. Atomic writes via tmp+rename. + * + * Token shape: + * - 32 random bytes, base64url-encoded (256 bits of entropy). + * - We store ONLY the SHA-256 hash on disk. The raw token lives in the + * email + in the URL query string; on the server we hash and look up. + * A read-only compromise of invites.json cannot forge acceptance. + * + * Lifecycle: + * - issue({ email, role, ttlMs, invitedBy }) → { id, token, expiresAt, ... } + * token is the only time the raw token will ever be returned. + * - peek(token) → { email, role, expiresAt, usedAt } | null + * (returns the public-safe info without consuming the token) + * - accept(token) → { ok: true, invite } | { ok: false, reason } + * reasons: 'not_found', 'expired', 'already_used' + * - revoke(id) → removes the invite by id (admin-only). + * - list() → all outstanding invites (admin-only). + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const platformPaths = require('../../platform-paths'); + +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d + +function _nowMs() { return Date.now(); } +function _nowIso() { return new Date().toISOString(); } + +function _atomicWriteJSON(filePath, data) { + const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now(); + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, filePath); +} + +function _readJSON(filePath, fallback) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err && err.code === 'ENOENT') return fallback; + return fallback; + } +} + +function _defaultData() { return { invites: {} }; } + +function _sha256(s) { + return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); +} + +function createInviteStore(opts = {}) { + // Same defensive resolver as user-store — universal-deps test proxies + // can return function-typed values for property access. + const candidates = [ + opts.dataDir, + opts.platformPaths && opts.platformPaths.dataDir, + platformPaths && platformPaths.dataDir, + ]; + const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0) + || require('os').tmpdir(); + const log = opts.log || { info() {}, warn() {}, error() {} }; + + const file = path.join(dataDir, 'invites.json'); + + let _mutex = Promise.resolve(); + function _enqueue(fn) { + const next = _mutex.then(fn, fn); + _mutex = next.catch(() => {}); + return next; + } + + function _load() { + const data = _readJSON(file, _defaultData()); + if (!data.invites || typeof data.invites !== 'object') data.invites = {}; + return data; + } + function _save(data) { _atomicWriteJSON(file, data); } + + function _prune(data) { + const cutoff = _nowMs() - PRUNE_AFTER_MS; + for (const id of Object.keys(data.invites)) { + const inv = data.invites[id]; + if (!inv) { delete data.invites[id]; continue; } + const isTerminal = inv.usedAt || (inv.expiresAt && new Date(inv.expiresAt).getTime() < cutoff); + if (isTerminal) delete data.invites[id]; + } + return data; + } + + /** + * Issue a new invite. Returns the raw token (only time it leaves the system). + */ + function issue({ email, role = 'operator', ttlMs = DEFAULT_TTL_MS, invitedBy = 'admin' } = {}) { + return _enqueue(() => { + if (typeof email !== 'string' || !email.includes('@')) { + return { ok: false, reason: 'invalid_email' }; + } + const normalized = email.toLowerCase().trim(); + const id = crypto.randomUUID(); + const token = crypto.randomBytes(32).toString('base64url'); + const hash = _sha256(token); + const issuedAt = _nowIso(); + const expiresAt = new Date(_nowMs() + ttlMs).toISOString(); + + const data = _load(); + _prune(data); + data.invites[id] = { + id, + hash, + email: normalized, + role, + invitedBy, + issuedAt, + expiresAt, + usedAt: null, + usedBy: null, + }; + _save(data); + + log.info && log.info('invite', 'invite issued', { + id, email: normalized, role, invitedBy, ttlMs, + }); + + return { + ok: true, + id, + token, // raw token — caller emails it + email: normalized, + role, + expiresAt, + ttlMs, + }; + }); + } + + /** + * Public-safe peek. Does NOT consume the token. + * Returns null if not found, expired, or already used (same response + * for all three — enumeration prevention). + */ + function peek(token) { + if (!token || typeof token !== 'string') return null; + return _enqueue(() => { + const data = _load(); + const hash = _sha256(token); + const inv = _findByHash(data, hash); + if (!inv) return null; + if (inv.usedAt) return null; + if (new Date(inv.expiresAt).getTime() < _nowMs()) return null; + return { + id: inv.id, + email: inv.email, + role: inv.role, + expiresAt: inv.expiresAt, + issuedAt: inv.issuedAt, + }; + }); + } + + /** + * Consume an invite token. Returns the invite record on success. + * After accept(), the invite is marked used (NOT deleted) so the admin + * can see who redeemed what. The auto-prune reaps it after 7 days. + */ + function accept(token, { acceptedBy } = {}) { + return _enqueue(() => { + if (!token || typeof token !== 'string') { + return { ok: false, reason: 'not_found' }; + } + const data = _load(); + const hash = _sha256(token); + const inv = _findByHash(data, hash); + if (!inv) return { ok: false, reason: 'not_found' }; + if (inv.usedAt) return { ok: false, reason: 'already_used' }; + if (new Date(inv.expiresAt).getTime() < _nowMs()) { + return { ok: false, reason: 'expired' }; + } + + inv.usedAt = _nowIso(); + inv.usedBy = acceptedBy || null; + _save(data); + + log.info && log.info('invite', 'invite accepted', { + id: inv.id, email: inv.email, role: inv.role, acceptedBy, + }); + + return { + ok: true, + invite: { + id: inv.id, + email: inv.email, + role: inv.role, + expiresAt: inv.expiresAt, + usedAt: inv.usedAt, + }, + }; + }); + } + + /** + * Admin-only. Revoke an outstanding invite by id. + */ + function revoke(id) { + return _enqueue(() => { + const data = _load(); + if (!data.invites[id]) return { ok: false, reason: 'not_found' }; + delete data.invites[id]; + _save(data); + log.info && log.info('invite', 'invite revoked', { id }); + return { ok: true }; + }); + } + + /** + * Admin-only. List outstanding invites (excludes used/expired). + */ + function listOutstanding() { + return _enqueue(() => { + const data = _load(); + _prune(data); + _save(data); + const now = _nowMs(); + return Object.values(data.invites) + .filter(inv => !inv.usedAt && new Date(inv.expiresAt).getTime() > now) + .sort((a, b) => new Date(a.expiresAt) - new Date(b.expiresAt)) + .map(inv => ({ + id: inv.id, + email: inv.email, + role: inv.role, + invitedBy: inv.invitedBy, + issuedAt: inv.issuedAt, + expiresAt: inv.expiresAt, + })); + }); + } + + function _findByHash(data, hash) { + for (const id of Object.keys(data.invites)) { + const inv = data.invites[id]; + if (inv && inv.hash === hash) return inv; + } + return null; + } + + return { + issue, + peek, + accept, + revoke, + listOutstanding, + DEFAULT_TTL_MS, + }; +} + +module.exports = { createInviteStore, DEFAULT_TTL_MS }; \ No newline at end of file diff --git a/dashcaddy-api/src/security/user-store.js b/dashcaddy-api/src/security/user-store.js new file mode 100644 index 0000000..ff96896 --- /dev/null +++ b/dashcaddy-api/src/security/user-store.js @@ -0,0 +1,407 @@ +/** + * User store — DC-048. + * + * Tracks who is allowed to log in to a DashCaddy instance, and what role each + * authenticated user has. Replaces the "one implicit operator" model that + * DC-046/047 shipped with. + * + * TWO files (both live under platformPaths.dataDir): + * + * data/users.json — every user that has ever authenticated. + * Shape: { + * users: { + * [userId]: { + * id, email, displayName, role, + * createdBy, createdAt, + * lastLoginAt, lastLoginIp, loginCount + * } + * }, + * order: [userId, ...] + * } + * + * data/authorized-users.json — the ALLOWLIST. Emails on this list may log in. + * The bootstrap user (first-ever login) is + * implicitly authorized even if the file is + * empty. Shape: { emails: ["a@x.com", ...] } + * + * Bootstrap rule: the FIRST email to ever successfully authenticate is + * automatically granted role "admin" AND implicitly added to the allowlist. + * This is recorded by writing a sentinel file `data/.bootstrapped` with the + * admin email so we never bootstrap twice (e.g. after a restore from backup). + * + * Atomic writes: every persistence op writes to a .tmp file then renames. + * process restart loses nothing in flight because rename is atomic on POSIX. + * + * Concurrency: a single in-process mutex serializes mutating ops. We don't + * need cross-process locks because this API is single-instance by design. + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const platformPaths = require('../../platform-paths'); + +const ROLES = Object.freeze({ + ADMIN: 'admin', + OPERATOR: 'operator', + VIEWER: 'viewer', +}); + +// All roles recognized by the system. Used for validation only. +const VALID_ROLES = new Set(Object.values(ROLES)); + +// Email shape — same pragmatic regex as the email provider. +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function _nowIso() { return new Date().toISOString(); } +function _isEmail(s) { return typeof s === 'string' && EMAIL_RE.test(s); } + +function _defaultUsers() { return { users: {}, order: [] }; } +function _defaultAllowlist() { return { emails: [] }; } + +// Coerce a candidate to a writable string dataDir; return null otherwise. +// Used by the factory's resolver to ignore test proxies / function-typed +// values from universal-deps that the `||` short-circuit can't filter. +function _resolveDataDir(opts) { + const candidates = [ + opts.dataDir, + opts.platformPaths && opts.platformPaths.dataDir, + platformPaths && platformPaths.dataDir, + ]; + for (const c of candidates) { + if (typeof c === 'string' && c.length > 0) return c; + } + return require('os').tmpdir(); +} + +function _atomicWriteJSON(filePath, data) { + const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now(); + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, filePath); +} + +function _readJSON(filePath, fallback) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err && err.code === 'ENOENT') return fallback; + // Corrupt file: log and return fallback so the API keeps serving. + // The next mutation will rewrite the file cleanly. + return fallback; + } +} + +/** + * Factory. One user-store per process. + * + * @param {Object} [opts] + * @param {string} [opts.dataDir] — override for tests + * @param {Object} [opts.log] — structured logger + */ +function createUserStore(opts = {}) { + // Resolve dataDir defensively — universal-deps test proxies can return + // function-typed values for property access, which `||` won't filter. + const dataDir = _resolveDataDir(opts); + const log = opts.log || { info() {}, warn() {}, error() {} }; + + const usersFile = path.join(dataDir, 'users.json'); + const allowlistFile = path.join(dataDir, 'authorized-users.json'); + const bootstrapSentinel = path.join(dataDir, '.bootstrapped'); + + let _mutex = Promise.resolve(); + + function _enqueue(fn) { + const next = _mutex.then(fn, fn); + // Swallow errors on the chain so one failure doesn't poison subsequent ops. + _mutex = next.catch(() => {}); + return next; + } + + function _loadUsers() { + const data = _readJSON(usersFile, _defaultUsers()); + if (!data.users || typeof data.users !== 'object') data.users = {}; + if (!Array.isArray(data.order)) data.order = Object.keys(data.users); + return data; + } + + function _loadAllowlist() { + const data = _readJSON(allowlistFile, _defaultAllowlist()); + if (!Array.isArray(data.emails)) data.emails = []; + return data; + } + + function _saveUsers(data) { _atomicWriteJSON(usersFile, data); } + function _saveAllowlist(data) { _atomicWriteJSON(allowlistFile, data); } + + function _bootstrapDone() { + try { return fs.existsSync(bootstrapSentinel); } + catch { return false; } + } + + function _writeBootstrapSentinel(adminEmail) { + _atomicWriteJSON(bootstrapSentinel, { + bootstrappedAt: _nowIso(), + adminEmail: adminEmail.toLowerCase(), + }); + } + + // ── Public API ───────────────────────────────────────────────────────── + + /** + * Authenticate-or-create a user from an email. Implements the DC-048 + * bootstrap rule and the authorized-users allowlist. + * + * Returns one of: + * { ok: true, user, role, isBootstrap } + * { ok: false, reason: 'not_authorized' } + * + * Reasons: + * 'not_authorized' — email not in allowlist AND bootstrap already happened. + * + * If bootstrap hasn't happened yet (no users file, no .bootstrapped sentinel), + * the first email that successfully passes shape validation becomes admin + * AND gets added to the allowlist atomically. + */ + function login({ email, ip, displayName, createdBy } = {}) { + return _enqueue(() => { + if (!_isEmail(email)) { + return { ok: false, reason: 'invalid_email' }; + } + const normalized = email.toLowerCase().trim(); + + const users = _loadUsers(); + const allowlist = _loadAllowlist(); + + // Existing user → just bump login counters. + const existing = _findUserByEmail(users, normalized); + if (existing) { + existing.lastLoginAt = _nowIso(); + existing.lastLoginIp = ip || ''; + existing.loginCount = (existing.loginCount || 0) + 1; + _saveUsers(users); + log.info && log.info('user', 'login existing user', { + userId: existing.id, email: normalized, role: existing.role, + }); + return { ok: true, user: existing, role: existing.role, isBootstrap: false }; + } + + // New email. Allow if (a) bootstrap hasn't happened, or (b) allowlisted. + const bootstrapPending = !_bootstrapDone() && users.order.length === 0; + const onAllowlist = allowlist.emails.includes(normalized); + + if (!bootstrapPending && !onAllowlist) { + log.info && log.info('user', 'login denied — not on allowlist', { email: normalized }); + return { ok: false, reason: 'not_authorized' }; + } + + // Bootstrap path: first-ever user becomes admin. + const isBootstrap = bootstrapPending; + const role = isBootstrap ? ROLES.ADMIN : ROLES.OPERATOR; + + const newUser = { + id: crypto.randomUUID(), + email: normalized, + displayName: displayName || normalized.split('@')[0], + role, + createdBy: createdBy || (isBootstrap ? 'bootstrap' : 'invite'), + createdAt: _nowIso(), + lastLoginAt: _nowIso(), + lastLoginIp: ip || '', + loginCount: 1, + }; + users.users[newUser.id] = newUser; + users.order.unshift(newUser.id); + + // If bootstrap: implicitly allowlist + write sentinel. + if (isBootstrap) { + if (!allowlist.emails.includes(normalized)) { + allowlist.emails.push(normalized); + } + _saveAllowlist(allowlist); + _writeBootstrapSentinel(normalized); + } + + _saveUsers(users); + + log.info && log.info('user', isBootstrap ? 'bootstrap admin created' : 'invited user created', { + userId: newUser.id, email: normalized, role, + }); + + return { ok: true, user: newUser, role, isBootstrap }; + }); + } + + /** + * Add an email to the allowlist WITHOUT creating a user record. Used when + * admin pre-authorizes someone who hasn't logged in yet. + * + * Returns { ok: true, alreadyExisted: boolean }. + */ + function addToAllowlist(email) { + return _enqueue(() => { + if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' }; + const normalized = email.toLowerCase().trim(); + const allowlist = _loadAllowlist(); + if (allowlist.emails.includes(normalized)) { + return { ok: true, alreadyExisted: true }; + } + allowlist.emails.push(normalized); + _saveAllowlist(allowlist); + log.info && log.info('user', 'added to allowlist', { email: normalized }); + return { ok: true, alreadyExisted: false }; + }); + } + + /** + * Remove an email from the allowlist. Does NOT delete the user record + * (so the admin can read the login history) — but future logins by that + * email will be rejected unless bootstrap re-runs (which it won't). + */ + function removeFromAllowlist(email) { + return _enqueue(() => { + if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' }; + const normalized = email.toLowerCase().trim(); + const allowlist = _loadAllowlist(); + const idx = allowlist.emails.indexOf(normalized); + if (idx === -1) return { ok: true, alreadyRemoved: true }; + allowlist.emails.splice(idx, 1); + _saveAllowlist(allowlist); + log.info && log.info('user', 'removed from allowlist', { email: normalized }); + return { ok: true, alreadyRemoved: false }; + }); + } + + /** + * Update an existing user's role. Role must be in VALID_ROLES. + * Returns { ok: true } or { ok: false, reason }. + */ + function setRole(userId, role) { + return _enqueue(() => { + if (!VALID_ROLES.has(role)) return { ok: false, reason: 'invalid_role' }; + const users = _loadUsers(); + const u = users.users[userId]; + if (!u) return { ok: false, reason: 'not_found' }; + u.role = role; + _saveUsers(users); + log.info && log.info('user', 'role updated', { userId, role }); + return { ok: true }; + }); + } + + /** + * Delete a user record AND remove from allowlist. Cannot delete the last + * admin (you'd lock yourself out). Returns { ok: true } or { ok: false, reason }. + */ + function deleteUser(userId) { + return _enqueue(() => { + const users = _loadUsers(); + const u = users.users[userId]; + if (!u) return { ok: false, reason: 'not_found' }; + + // Count remaining admins. + const remainingAdmins = users.order + .map(id => users.users[id]) + .filter(x => x && x.role === ROLES.ADMIN && x.id !== userId).length; + if (u.role === ROLES.ADMIN && remainingAdmins === 0) { + return { ok: false, reason: 'last_admin' }; + } + + delete users.users[userId]; + users.order = users.order.filter(id => id !== userId); + + // Also remove from allowlist so re-invite is a clean slate. + const allowlist = _loadAllowlist(); + const idx = allowlist.emails.indexOf(u.email); + if (idx !== -1) { + allowlist.emails.splice(idx, 1); + _saveAllowlist(allowlist); + } + + _saveUsers(users); + log.info && log.info('user', 'user deleted', { userId, email: u.email }); + return { ok: true }; + }); + } + + function listUsers() { + return _enqueue(() => { + const users = _loadUsers(); + return users.order + .map(id => users.users[id]) + .filter(Boolean); + }); + } + + function listAllowlist() { + return _enqueue(() => { + const allowlist = _loadAllowlist(); + return [...allowlist.emails]; + }); + } + + function getUser(userId) { + return _enqueue(() => { + const users = _loadUsers(); + return users.users[userId] || null; + }); + } + + function getUserByEmail(email) { + return _enqueue(() => { + if (!_isEmail(email)) return null; + const users = _loadUsers(); + return _findUserByEmail(users, email.toLowerCase().trim()) || null; + }); + } + + function isBootstrapComplete() { + return _enqueue(() => _bootstrapDone()); + } + + /** + * Helper for the auth system: given an email, return whether the user + * is allowed to attempt login (allowlist OR bootstrap-pending). Used by + * the email provider's `authorizedEmails()` dependency. + */ + function isEmailAuthorized(email) { + return _enqueue(() => { + if (!_isEmail(email)) return false; + const normalized = email.toLowerCase().trim(); + const allowlist = _loadAllowlist(); + if (allowlist.emails.includes(normalized)) return true; + const users = _loadUsers(); + // Bootstrap path: if no users yet, the first login is implicitly allowed. + return users.order.length === 0 && !_bootstrapDone(); + }); + } + + function _findUserByEmail(users, normalizedEmail) { + for (const id of users.order) { + const u = users.users[id]; + if (u && u.email === normalizedEmail) return u; + } + return null; + } + + return { + login, + addToAllowlist, + removeFromAllowlist, + setRole, + deleteUser, + listUsers, + listAllowlist, + getUser, + getUserByEmail, + isBootstrapComplete, + isEmailAuthorized, + // Constants for callers + ROLES, + VALID_ROLES, + }; +} + +module.exports = { createUserStore, ROLES, VALID_ROLES }; \ No newline at end of file diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 9523b63..2823696 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -335,6 +335,14 @@ module.exports = function configureMiddleware(app, { { path: '/api/v1/auth/login/:provider/verify', exact: true, method: 'POST' }, { path: '/api/v1/auth/login/recovery-info', exact: true, method: 'GET' }, { path: '/api/v1/auth/disable/:provider', exact: true, method: 'POST' }, + // DC-048: invite redemption is PUBLIC (recipient comes from an email + // link with no session cookie). The peek route is also public so the + // UI can show "this invite is for X, expires Y" before clicking. + { path: '/api/v1/auth/invites/:token', exact: true, method: 'GET' }, + { path: '/api/v1/auth/invites/:token/accept', exact: true, method: 'POST' }, + // /me and /admin/* require authentication — NOT public. Listed here + // only to document them; absence from PUBLIC_ROUTES means they go + // through the normal auth gate. CSRF applies to writes as usual. { path: '/api/v1/services', exact: true, method: 'GET' }, { path: '/api/v1/ca/info', exact: true, method: 'GET' }, { path: '/api/v1/ca/root.crt', exact: true, method: 'GET' }, diff --git a/status/build.js b/status/build.js index dfcd8b2..253efaf 100644 --- a/status/build.js +++ b/status/build.js @@ -30,6 +30,10 @@ const bundles = { JS('totp-recovery.js'), JS('service-credentials.js'), JS('totp-settings.js'), + // DC-048 admin panel — modal-overlay UI for user/invite management. + // Renders the "Admin" trigger button into the top bar; only visible + // when /api/v1/auth/me returns isAdmin=true. + JS('admin.js'), JS('core', 'credentials.js'), JS('core', 'grid.js'), JS('core', 'dns.js'), diff --git a/status/dist/core.js b/status/dist/core.js index 2eeb2ec..02d2005 100644 --- a/status/dist/core.js +++ b/status/dist/core.js @@ -1,4 +1,4 @@ -(function(a){"use strict";class y{constructor(){this.errors=[],this.maxErrors=50}logError(v,i,s={}){const m={timestamp:new Date().toISOString(),context:v,message:i instanceof Error?i.message:i,stack:i instanceof Error?i.stack:null,metadata:s};this.errors.push(m),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${v}:`,i,s)}recoverFromError(v,i){switch(this.classifyError(v)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",v,{currentStep:i}),{action:"SKIP_STEP",nextStep:i+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",v),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",v),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",v,{currentStep:i}),{action:"SKIP_STEP",nextStep:i+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",v),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",v,{currentStep:i}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(v){const i=v.message||v.toString();return i.includes("element")&&i.includes("not found")?"ELEMENT_NOT_FOUND":i.includes("storage")||i.includes("quota")?"STORAGE_UNAVAILABLE":i.includes("driver")||i.includes("undefined")?"DRIVER_NOT_LOADED":i.includes("invalid")||i.includes("validation")?"INVALID_TOOLTIP":i.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const v={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(i=>{v.byContext[i.context]=(v.byContext[i.context]||0)+1;const s=this.classifyError({message:i.message});v.byType[s]=(v.byType[s]||0)+1}),v}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const v=document.createElement("div");return v.id="onboarding-fallback",v.style.cssText=` +(function(l){"use strict";class n{constructor(){this.errors=[],this.maxErrors=50}logError(b,f,u={}){const v={timestamp:new Date().toISOString(),context:b,message:f instanceof Error?f.message:f,stack:f instanceof Error?f.stack:null,metadata:u};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${b}:`,f,u)}recoverFromError(b,f){switch(this.classifyError(b)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",b,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",b),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",b),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",b,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",b),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",b,{currentStep:f}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(b){const f=b.message||b.toString();return f.includes("element")&&f.includes("not found")?"ELEMENT_NOT_FOUND":f.includes("storage")||f.includes("quota")?"STORAGE_UNAVAILABLE":f.includes("driver")||f.includes("undefined")?"DRIVER_NOT_LOADED":f.includes("invalid")||f.includes("validation")?"INVALID_TOOLTIP":f.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const b={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(f=>{b.byContext[f.context]=(b.byContext[f.context]||0)+1;const u=this.classifyError({message:f.message});b.byType[u]=(b.byType[u]||0)+1}),b}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const b=document.createElement("div");return b.id="onboarding-fallback",b.style.cssText=` position: fixed; bottom: 20px; right: 20px; @@ -10,44 +10,78 @@ z-index: 9999; max-width: 300px; font-size: 14px; - `,v.innerHTML=` + `,b.innerHTML=` Welcome to DashCaddy!

The interactive tour is unavailable, but you can explore the dashboard freely. Check the documentation for help getting started.

- `,document.body.appendChild(v),setTimeout(()=>{v.parentNode&&v.parentNode.removeChild(v)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const v={data:{},getItem(i){return this.data[i]||null},setItem(i,s){this.data[i]=s},removeItem(i){delete this.data[i]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),v}sendToErrorTracking(v){}}a.ErrorHandler=y,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const v=await fetch("/api/v1/config");if(v.ok){const i=await v.json();if(i.tld&&(SITE.tld=i.tld.startsWith(".")?i.tld:"."+i.tld),i.dns&&(SITE.dnsIp=i.dns.ip||"",SITE.dnsPort=i.dns.port||DC.DEFAULTS.DNS_PORT),i.dnsServers&&typeof i.dnsServers=="object")for(const[m,t]of Object.entries(i.dnsServers))m!=="__proto__"&&m!=="constructor"&&m!=="prototype"&&(SITE.dnsServers[m]=t);i.configurationType&&(SITE.configurationType=i.configurationType),i.domain&&(SITE.domain=i.domain),i.defaults&&(SITE.defaults=i.defaults),i.routingMode&&(SITE.routingMode=i.routingMode),SITE.onboardingCompleted=i.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const s=document.getElementById("manage-tokens");s&&(s.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(v=>v.textContent=SITE.tld);const y=document.getElementById("edit-tld-suffix");y&&(y.textContent=SITE.tld);const p=document.getElementById("external-proxy-ip");p&&SITE.dnsIp&&(p.value=SITE.dnsIp,p.placeholder=SITE.dnsIp)})();function buildDomain(a){return a+SITE.tld}function buildServiceUrl(a){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+a:SITE.configurationType==="public"&&SITE.domain?"https://"+a+"."+SITE.domain:"https://"+buildDomain(a)}function getDnsServerAddr(a){const y=SITE.dnsServers[a];return y?`${y.ip}:${y.port}`:buildDomain(a)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[a,y]of Object.entries(SITE.dnsServers))if(y.ip===SITE.dnsIp)return a;return null}function renderDnsCards(){const a=document.querySelector(".top");if(!a)return;const y=Object.keys(SITE.dnsServers);if(!y.length)return;const p='',v=a.firstElementChild;y.forEach(i=>{const s=escapeHtml(i),m=escapeHtml((SITE.dnsServers[i].name||i).toUpperCase()),t=document.createElement("div");t.className="card",t.setAttribute("data-app",i),t.setAttribute("data-status","off"),t.innerHTML=`
${p}
${m}OFF
--
--
`,a.insertBefore(t,v)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const a=await fetch("/api/v1/csrf-token");if(!a.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await a.json()).token,csrfToken}catch(a){throw errorHandler.logError("[CSRF] Get Token",a,{function:"getCSRFToken"}),a}}async function secureFetch(a,y={}){const p=(y.method||"GET").toUpperCase(),v=!["GET","HEAD","OPTIONS"].includes(p);if(v)try{const s=await getCSRFToken();y.headers={...y.headers,"X-CSRF-Token":s}}catch(s){errorHandler.logError("[CSRF] Add to Request",s,{function:"secureFetch"})}y.signal||(y={...y,signal:AbortSignal.timeout(15e3)});const i=await fetch(a,y);if(v&&i.status===403)try{const s=await i.clone().json();if(s.error&&(s.error.includes("DC-100")||s.error.includes("DC-101"))){csrfToken=null;const m=await getCSRFToken();return y.headers={...y.headers,"X-CSRF-Token":m},y.signal=AbortSignal.timeout(15e3),fetch(a,y)}}catch{}return i}async function postJSON(a,y){const p=await secureFetch(a,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(y)}),v=await p.json();if(!p.ok||v.success===!1)throw new Error(v.error||`Request failed (${p.status})`);return v}async function getJSON(a){const y=await secureFetch(a);if(!y.ok){let p=`Request failed (${y.status})`;try{p=(await y.json()).error||p}catch{}throw new Error(p)}return y.json()}async function deleteAPI(a){const y=await secureFetch(a,{method:"DELETE"}),p=await y.json();if(!y.ok||p.success===!1)throw new Error(p.error||`Delete failed (${y.status})`);return p}async function withButton(a,y,p,v={}){const i=a.innerHTML,{successText:s="\u2705",resetDelay:m=DC.DELAYS.BTN_RESET}=v;a.disabled=!0,a.innerHTML=y;try{const t=await p();return a.innerHTML=s,setTimeout(()=>{a.innerHTML=i,a.disabled=!1},m),t}catch(t){throw a.innerHTML=i,a.disabled=!1,t}}function openModal(a){document.getElementById(a)?.classList.add("show")}function closeModal(a){document.getElementById(a)?.classList.remove("show")}function wireModal(a,...y){a&&(a.addEventListener("click",p=>{p.target===a&&a.classList.remove("show")}),y.forEach(p=>{p&&typeof p.addEventListener=="function"&&p.addEventListener("click",()=>a.classList.remove("show"))}))}function showNotification(a,y="info",p=3e3){const v=document.querySelector(".deploy-notification");v&&v.remove();const i={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},s=i[y]||i.info,m=document.createElement("div");m.className="deploy-notification",m.textContent=a,m.style.cssText=` + `,document.body.appendChild(b),setTimeout(()=>{b.parentNode&&b.parentNode.removeChild(b)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const b={data:{},getItem(f){return this.data[f]||null},setItem(f,u){this.data[f]=u},removeItem(f){delete this.data[f]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),b}sendToErrorTracking(b){}}l.ErrorHandler=n,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const b=await fetch("/api/v1/config");if(b.ok){const f=await b.json();if(f.tld&&(SITE.tld=f.tld.startsWith(".")?f.tld:"."+f.tld),f.dns&&(SITE.dnsIp=f.dns.ip||"",SITE.dnsPort=f.dns.port||DC.DEFAULTS.DNS_PORT),f.dnsServers&&typeof f.dnsServers=="object")for(const[v,s]of Object.entries(f.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=s);f.configurationType&&(SITE.configurationType=f.configurationType),f.domain&&(SITE.domain=f.domain),f.defaults&&(SITE.defaults=f.defaults),f.routingMode&&(SITE.routingMode=f.routingMode),SITE.onboardingCompleted=f.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const u=document.getElementById("manage-tokens");u&&(u.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(b=>b.textContent=SITE.tld);const n=document.getElementById("edit-tld-suffix");n&&(n.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.placeholder=SITE.dnsIp)})();function buildDomain(l){return l+SITE.tld}function buildServiceUrl(l){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+l:SITE.configurationType==="public"&&SITE.domain?"https://"+l+"."+SITE.domain:"https://"+buildDomain(l)}function getDnsServerAddr(l){const n=SITE.dnsServers[l];return n?`${n.ip}:${n.port}`:buildDomain(l)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[l,n]of Object.entries(SITE.dnsServers))if(n.ip===SITE.dnsIp)return l;return null}function renderDnsCards(){const l=document.querySelector(".top");if(!l)return;const n=Object.keys(SITE.dnsServers);if(!n.length)return;const g='',b=l.firstElementChild;n.forEach(f=>{const u=escapeHtml(f),v=escapeHtml((SITE.dnsServers[f].name||f).toUpperCase()),s=document.createElement("div");s.className="card",s.setAttribute("data-app",f),s.setAttribute("data-status","off"),s.innerHTML=`
${g}
${v}OFF
--
--
`,l.insertBefore(s,b)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const l=await fetch("/api/v1/csrf-token");if(!l.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await l.json()).token,csrfToken}catch(l){throw errorHandler.logError("[CSRF] Get Token",l,{function:"getCSRFToken"}),l}}async function secureFetch(l,n={}){const g=(n.method||"GET").toUpperCase(),b=!["GET","HEAD","OPTIONS"].includes(g);if(b)try{const u=await getCSRFToken();n.headers={...n.headers,"X-CSRF-Token":u}}catch(u){errorHandler.logError("[CSRF] Add to Request",u,{function:"secureFetch"})}n.signal||(n={...n,signal:AbortSignal.timeout(15e3)});const f=await fetch(l,n);if(b&&f.status===403)try{const u=await f.clone().json();if(u.error&&(u.error.includes("DC-100")||u.error.includes("DC-101"))){csrfToken=null;const v=await getCSRFToken();return n.headers={...n.headers,"X-CSRF-Token":v},n.signal=AbortSignal.timeout(15e3),fetch(l,n)}}catch{}return f}async function postJSON(l,n){const g=await secureFetch(l,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)}),b=await g.json();if(!g.ok||b.success===!1)throw new Error(b.error||`Request failed (${g.status})`);return b}async function getJSON(l){const n=await secureFetch(l);if(!n.ok){let g=`Request failed (${n.status})`;try{g=(await n.json()).error||g}catch{}throw new Error(g)}return n.json()}async function deleteAPI(l){const n=await secureFetch(l,{method:"DELETE"}),g=await n.json();if(!n.ok||g.success===!1)throw new Error(g.error||`Delete failed (${n.status})`);return g}async function withButton(l,n,g,b={}){const f=l.innerHTML,{successText:u="\u2705",resetDelay:v=DC.DELAYS.BTN_RESET}=b;l.disabled=!0,l.innerHTML=n;try{const s=await g();return l.innerHTML=u,setTimeout(()=>{l.innerHTML=f,l.disabled=!1},v),s}catch(s){throw l.innerHTML=f,l.disabled=!1,s}}function openModal(l){document.getElementById(l)?.classList.add("show")}function closeModal(l){document.getElementById(l)?.classList.remove("show")}function wireModal(l,...n){l&&(l.addEventListener("click",g=>{g.target===l&&l.classList.remove("show")}),n.forEach(g=>{g&&typeof g.addEventListener=="function"&&g.addEventListener("click",()=>l.classList.remove("show"))}))}function showNotification(l,n="info",g=3e3){const b=document.querySelector(".deploy-notification");b&&b.remove();const f={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},u=f[n]||f.info,v=document.createElement("div");v.className="deploy-notification",v.textContent=l,v.style.cssText=` position: fixed; top: 20px; right: 20px; - background: ${s.bg}; color: ${s.fg}; + background: ${u.bg}; color: ${u.fg}; padding: 16px 24px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.3); z-index: 10000; animation: slideIn 0.3s ease-out; max-width: 400px; white-space: pre-line; font-size: 14px; - `,document.body.appendChild(m),p>0&&setTimeout(()=>m.remove(),p)}function timeAgo(a){const y=Date.now()-new Date(a).getTime();return y<6e4?"just now":y<36e5?Math.floor(y/6e4)+"m ago":y<864e5?Math.floor(y/36e5)+"h ago":Math.floor(y/864e5)+"d ago"}function safeGet(a,y=null){try{const p=localStorage.getItem(a);return p!==null?p:y}catch{return y}}function safeSet(a,y){try{localStorage.setItem(a,y)}catch{}}function safeRemove(a){try{localStorage.removeItem(a)}catch{}}function safeSessionGet(a,y=null){try{const p=sessionStorage.getItem(a);return p!==null?p:y}catch{return y}}function safeSessionSet(a,y){try{sessionStorage.setItem(a,y)}catch{}}function safeGetJSON(a,y=null){try{const p=localStorage.getItem(a);return p?JSON.parse(p):y}catch{return y}}function escapeHtml(a){return String(a??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(a,y){document.getElementById(a)||document.body.insertAdjacentHTML("beforeend",y)}const DC_BUS={_handlers:{},on(a,y){var p;((p=this._handlers)[a]||(p[a]=[])).push(y)},off(a,y){this._handlers[a]=this._handlers[a]?.filter(p=>p!==y)},emit(a,y){this._handlers[a]?.forEach(p=>p(y))}},AppState={_apps:[],getApps(){return this._apps},setApps(a){this._apps=a,window.APPS=a,DC_BUS.emit("apps:changed",a)},findApp(a){return this._apps.find(y=>y.id===a)},addApp(a){this._apps.push(a),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(a){const y=this._apps.findIndex(p=>p.id===a);return y>-1&&(this._apps.splice(y,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),y>-1},updateApp(a,y){const p=this._apps.find(v=>v.id===a);if(p){for(const[v,i]of Object.entries(y))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(p[v]=i);DC_BUS.emit("apps:changed",this._apps)}return p}};(function(){function a(){const v=document.createElement("div");return v.className="skeleton-card",v.innerHTML='
',v}function y(v){const i=document.getElementById("cards");if(!(!i||i.querySelector(".card"))){v=v||6;for(let s=0;s.4,A={};return A.hover=S?l(w,B,.35):l(w,$,.08),A["card-hover"]=l(w,A.hover,.5),A.base=l(B,w,.6),A["fg-muted"]=l(x,B,.35),A.success=I,A.error=k,A.warning=S?"#d68a00":"#f39c12",A}function r(C,B){var $=B.lightBg||B.bg&&g(B.bg)>.4,x=B.accent||B["accent-strong"]||"#888888",w=c(x);return $?":root."+C+` body { + `,document.body.appendChild(v),g>0&&setTimeout(()=>v.remove(),g)}function timeAgo(l){const n=Date.now()-new Date(l).getTime();return n<6e4?"just now":n<36e5?Math.floor(n/6e4)+"m ago":n<864e5?Math.floor(n/36e5)+"h ago":Math.floor(n/864e5)+"d ago"}function safeGet(l,n=null){try{const g=localStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSet(l,n){try{localStorage.setItem(l,n)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,n=null){try{const g=sessionStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSessionSet(l,n){try{sessionStorage.setItem(l,n)}catch{}}function safeGetJSON(l,n=null){try{const g=localStorage.getItem(l);return g?JSON.parse(g):n}catch{return n}}function escapeHtml(l){return String(l??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(l,n){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",n)}const DC_BUS={_handlers:{},on(l,n){var g;((g=this._handlers)[l]||(g[l]=[])).push(n)},off(l,n){this._handlers[l]=this._handlers[l]?.filter(g=>g!==n)},emit(l,n){this._handlers[l]?.forEach(g=>g(n))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(n=>n.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const n=this._apps.findIndex(g=>g.id===l);return n>-1&&(this._apps.splice(n,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),n>-1},updateApp(l,n){const g=this._apps.find(b=>b.id===l);if(g){for(const[b,f]of Object.entries(n))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(g[b]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function l(){const b=document.createElement("div");return b.className="skeleton-card",b.innerHTML='
',b}function n(b){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){b=b||6;for(let u=0;u.4,A={};return A.hover=S?m(x,B,.35):m(x,$,.08),A["card-hover"]=m(x,A.hover,.5),A.base=m(B,x,.6),A["fg-muted"]=m(C,B,.35),A.success=I,A.error=k,A.warning=S?"#d68a00":"#f39c12",A}function o(E,B){var $=B.lightBg||B.bg&&d(B.bg)>.4,C=B.accent||B["accent-strong"]||"#888888",x=c(C);return $?":root."+E+` body { background: - radial-gradient(1200px 800px at 10% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .08), transparent 60%), - radial-gradient(1000px 700px at 110% 10%, rgba(`+w.r+","+w.g+","+w.b+`, .05), transparent 55%), + radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%), + radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%), var(--bg); } -`:":root."+C+` body { +`:":root."+E+` body { background: - radial-gradient(1200px 900px at 8% -12%, rgba(`+w.r+","+w.g+","+w.b+`, .10), transparent 60%), - radial-gradient(1000px 700px at 110% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .07), transparent 55%), + radial-gradient(1200px 900px at 8% -12%, rgba(`+x.r+","+x.g+","+x.b+`, .10), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%), var(--bg); } -`}function f(C,B){var $=B.lightBg||B.bg&&g(B.bg)>.4;return $?":root."+C+` button:hover { +`}function i(E,B){var $=B.lightBg||B.bg&&d(B.bg)>.4;return $?":root."+E+` button:hover { background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); border-color: rgba(0, 0, 0, .15); box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8); } -`:":root."+C+` button:hover { +`:":root."+E+` button:hover { background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } -`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function u(){s.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function b(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),v.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(y,{}),w=$,I=2;x[$]&&$!==B;)$=w+"-"+I++;return $}function d(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),i.length=v.length,Object.keys(h).forEach(function(k){v.indexOf(k)===-1&&delete h[k]});var $=C||safeGetJSON(y,{}),x=Object.keys($);if(x=x.filter(function(k){return v.indexOf(k)===-1}),!!x.length){var w="";x.forEach(function(k){var S=$[k];i.indexOf(k)===-1&&i.push(k);var A={};s.forEach(function(D){S[D]&&(A[D]=S[D])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var O=e(A);t.forEach(function(D){!A[D]&&O[D]&&(A[D]=O[D])}),h[k]=A,w+=":root."+k+` { -`,s.forEach(function(D){A[D]&&(w+=" --"+D+": "+A[D]+`; -`)}),w+=`} -`,w+=r(k,A),w+=f(k,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=w,document.head.appendChild(I)}}function E(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(y,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(y,JSON.stringify(B)),d(B);var x=safeGet(a);x&&i.indexOf(x)!==-1&&L(x)}}}).catch(function(){})}function T(){var C=safeGetJSON(p);if(C){var B=C.name||"Custom",$=b(B),x={name:B};s.forEach(function(k){C[k]&&(x[k]=C[k])});var w=safeGetJSON(y,{});w[$]=x,safeSet(y,JSON.stringify(w)),safeGet(a)==="custom"&&safeSet(a,$),safeRemove(p);var I={};s.forEach(function(k){x[k]&&(I[k]=x[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),i.forEach(function(w){w!=="dark"&&document.documentElement.classList.remove(w)}),u(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(a,C);var B=h[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var x=B&&B.lightBg;!x&&B&&B.bg&&(x=g(B.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),d();var P=safeGet(a);P==="red"&&(P="black",safeSet(a,"black")),P&&P!=="dark"&&i.indexOf(P)===-1&&(P=null),L(P||n()),E(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(a)||L(C.matches?"dark":"light")}),window.THEMES=i,window.BUILTIN_THEMES=v,window.THEME_COLORS=h,window.THEME_PROPS=s,window.BASE_PROPS=m,window.DERIVED_PROPS=t,window.USER_THEMES_KEY=y,window.applyTheme=L,window.clearCustomProperties=u,window.injectUserThemeStyles=d,window.syncThemesFromServer=E,window.slugifyThemeName=b,window.getActiveTheme=function(){return safeGet(a)||n()},window.deriveExtendedColors=e,window.hexToRgb=c,window.rgbToHex=o,window.blendColors=l})(),(function(){function a(){const m=document.querySelector(".totp-card");if(!m)return;const h=getComputedStyle(m).backgroundColor.match(/\d+/g);if(!h)return;const c=(.299*+h[0]+.587*+h[1]+.114*+h[2])/255,o=m.querySelector(".totp-logo-dark"),l=m.querySelector(".totp-logo-light");o&&(o.style.display=c>.5?"none":""),l&&(l.style.display=c>.5?"":"none")}function y(){const m=document.getElementById("totp-overlay");if(m){m.classList.add("show"),setTimeout(a,50);const t=m.querySelector(".totp-digits input");t&&setTimeout(()=>t.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function p(){const m=document.getElementById("totp-overlay");m&&m.classList.remove("show")}const v=document.getElementById("totp-digits");if(v){const m=v.querySelectorAll("input");m.forEach((t,h)=>{t.addEventListener("input",c=>{const o=c.target.value.replace(/\D/g,"");c.target.value=o.slice(0,1),o&&hg.value).join("");l.length===6&&i(l)}),t.addEventListener("keydown",c=>{c.key==="Backspace"&&!c.target.value&&h>0&&(m[h-1].focus(),m[h-1].value="")}),t.addEventListener("paste",c=>{c.preventDefault();const o=(c.clipboardData.getData("text")||"").replace(/\D/g,"");o.length>=6&&(m.forEach((l,g)=>{l.value=o[g]||""}),m[5].focus(),i(o.slice(0,6)))})})}async function i(m){const t=document.getElementById("totp-error");t.textContent="Verifying...",t.className="totp-error verifying";try{const c=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:m})})).json();if(c.success){t.textContent="",c.csrfToken&&(csrfToken=c.csrfToken),p();const o=safeSessionGet("totp_redirect");if(o){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=o;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{t.textContent=c.error||"Invalid code",t.className="totp-error";const o=document.querySelectorAll("#totp-digits input");o.forEach(l=>{l.value=""}),o[0]?.focus()}}catch{t.textContent="Connection error",t.className="totp-error"}}const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const m=s.get("return");if(m)try{const t=new URL(m,window.location.origin),h=t.hostname,c=t.origin===window.location.origin,o=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,l=h.endsWith(o)||h===o.substring(1);(c||l)&&safeSessionSet("totp_redirect",m)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=y})(),(function(){"use strict";async function a(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function y(m){const t=document.getElementById("totp-recovery-link");t&&(t.style.display=m?"":"none")}function p(){const m=document.getElementById("totp-recovery-panel");m&&(m.style.display="");const t=document.getElementById("totp-recovery-status"),h=document.getElementById("totp-recovery-import"),c=document.getElementById("totp-recovery-verify");h&&(h.style.display=""),c&&(c.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",a().then(o=>{t.textContent=o.hint||"",o.status==="healthy"?t.style.borderColor="var(--ok-fg, #7ef2ff)":o.status==="unreadable"?(t.style.borderColor="var(--bad-fg, #ff9aa3)",t.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):o.status==="not_configured"?t.style.borderColor="var(--muted)":t.style.borderColor="var(--border)"}),setTimeout(()=>{document.getElementById("totp-recovery-secret")?.focus()},100)}function v(){const m=document.getElementById("totp-recovery-panel");m&&(m.style.display="none")}async function i(){const m=document.getElementById("totp-recovery-secret").value.trim(),t=document.getElementById("totp-recovery-error");if(t.textContent="",!m){t.textContent="Paste your Base32 key first";return}if(!/^[A-Za-z2-7\s]+=*$/.test(m)){t.textContent="Invalid Base32 format \u2014 should be letters A-Z and digits 2-7 only";return}try{const h=await fetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:m})}),c=await h.json();if(!h.ok||!c.success){t.textContent=c.error||c.message||"Restore failed";return}document.getElementById("totp-recovery-import").style.display="none",document.getElementById("totp-recovery-verify").style.display="",setTimeout(()=>document.getElementById("totp-recovery-code")?.focus(),100)}catch{t.textContent="Network error \u2014 try again"}}async function s(){const m=document.getElementById("totp-recovery-code").value.trim(),t=document.getElementById("totp-recovery-confirm-error");if(t.textContent="",!/^\d{6}$/.test(m)){t.textContent="Enter a 6-digit code";return}try{const h=await fetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:m})}),c=await h.json();if(!h.ok||!c.success){t.textContent=c.error||c.message||"Invalid code",document.getElementById("totp-recovery-code").value="",document.getElementById("totp-recovery-code")?.focus();return}v();const o=document.getElementById("totp-overlay");o&&o.classList.remove("show"),typeof window.initializeDashboard=="function"&&window.initializeDashboard()}catch{t.textContent="Network error \u2014 try again"}}document.getElementById("totp-show-recovery")?.addEventListener("click",m=>{m.preventDefault(),p()}),document.getElementById("totp-recovery-close")?.addEventListener("click",v),document.getElementById("totp-recovery-submit")?.addEventListener("click",i),document.getElementById("totp-recovery-confirm")?.addEventListener("click",s),document.getElementById("totp-recovery-secret")?.addEventListener("keydown",m=>{m.key==="Enter"&&(m.preventDefault(),i())}),document.getElementById("totp-recovery-code")?.addEventListener("keydown",m=>{m.key==="Enter"&&(m.preventDefault(),s())}),window._refreshRecoveryLink=async function(){const m=await a();return m&&m.success&&m.status&&m.status!=="healthy"?y(!0):y(!1),m}})(),(function(){const a=new ErrorHandler;injectModal("folder-browser-modal",`
+`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function a(){u.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function h(E,B){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),b.indexOf($)!==-1&&($=$+"-custom");for(var C=safeGetJSON(n,{}),x=$,I=2;C[$]&&$!==B;)$=x+"-"+I++;return $}function p(E){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=b.length,Object.keys(y).forEach(function(k){b.indexOf(k)===-1&&delete y[k]});var $=E||safeGetJSON(n,{}),C=Object.keys($);if(C=C.filter(function(k){return b.indexOf(k)===-1}),!!C.length){var x="";C.forEach(function(k){var S=$[k];f.indexOf(k)===-1&&f.push(k);var A={};u.forEach(function(O){S[O]&&(A[O]=S[O])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var D=e(A);s.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),y[k]=A,x+=":root."+k+` { +`,u.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`; +`)}),x+=`} +`,x+=o(k,A),x+=i(k,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var B=E.themes,$=safeGetJSON(n,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(n,JSON.stringify(B)),p(B);var C=safeGet(l);C&&f.indexOf(C)!==-1&&L(C)}}}).catch(function(){})}function T(){var E=safeGetJSON(g);if(E){var B=E.name||"Custom",$=h(B),C={name:B};u.forEach(function(k){E[k]&&(C[k]=E[k])});var x=safeGetJSON(n,{});x[$]=C,safeSet(n,JSON.stringify(x)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(g);var I={};u.forEach(function(k){C[k]&&(I[k]=C[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(E){document.documentElement.classList.add("theme-transitioning"),f.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),a(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(l,E);var B=y[E],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var C=B&&B.lightBg;!C&&B&&B.bg&&(C=d(B.bg)>.4),C?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),p();var P=safeGet(l);P==="red"&&(P="black",safeSet(l,"black")),P&&P!=="dark"&&f.indexOf(P)===-1&&(P=null),L(P||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(l)||L(E.matches?"dark":"light")}),window.THEMES=f,window.BUILTIN_THEMES=b,window.THEME_COLORS=y,window.THEME_PROPS=u,window.BASE_PROPS=v,window.DERIVED_PROPS=s,window.USER_THEMES_KEY=n,window.applyTheme=L,window.clearCustomProperties=a,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=h,window.getActiveTheme=function(){return safeGet(l)||t()},window.deriveExtendedColors=e,window.hexToRgb=c,window.rgbToHex=r,window.blendColors=m})(),(function(){let l=null;async function n(){if(l)return l;try{const y=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!y.ok)throw new Error(`methods HTTP ${y.status}`);const c=await y.json();return l=Array.isArray(c.providers)?c.providers:[],l}catch(y){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",y),[]}}function g(y){const c=document.getElementById("totp-overlay");if(!c)return;const r=c.querySelector(".totp-card");if(!r)return;const m=r.innerHTML;r.dataset.originalBody||(r.dataset.originalBody=m);const d=y.map(e=>{const o=e.config&&(e.config.label||e.name)||e.name;return``}).join(` +`);r.innerHTML=` + + +

Choose how to sign in

+
${d}
+
+ `,c.classList.add("show"),r.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const o=e.dataset.provider,i=y.find(t=>t.name===o);f(i)})})}function b(){const y=document.getElementById("totp-overlay");if(!y)return;const c=y.querySelector(".totp-card");!c||!c.dataset.originalBody||(c.innerHTML=c.dataset.originalBody,window.location.reload())}function f(y){const c=document.getElementById("totp-overlay");if(!c)return;const r=c.querySelector(".totp-card");if(r){if(y.name==="totp"){window.location.reload();return}if(y.name==="email"){r.innerHTML=` + + +

Sign in with email

+

+ We'll email you a one-time sign-in link. +

+ + +
+
+ \u2190 Back +
+ `,c.classList.add("show");const m=r.querySelector("#auth-gate-email-input"),d=r.querySelector("#auth-gate-email-submit"),e=r.querySelector("#auth-gate-email-status"),o=r.querySelector("#auth-gate-back");d.addEventListener("click",async()=>{const i=(m.value||"").trim();if(!i||!i.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}d.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const a=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:i})})).json();if(a.success){const h=a.deliveredVia||"email",p=a.maskedEmail||i;h==="dev-console"?e.innerHTML=` + Check the server logs for your one-time link. + (Dev mode: no SMTP configured. In production this would email ${p}.)`:e.innerHTML=`Sign-in link sent to ${p}. + Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=a.error||"Could not send link.",e.style.color="var(--error, #d33)",d.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",d.disabled=!1}}),m.addEventListener("keydown",i=>{i.key==="Enter"&&d.click()}),o.addEventListener("click",i=>{i.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",y.name),window.location.reload()}}async function u(){if(!document.getElementById("totp-overlay"))return;const c=await n();if(c.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(c.length===1&&c[0].name==="totp"){v(c[0]);return}g(c)}function v(y){const c=l&&l.find(e=>e.name==="email");if(!c){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const r=document.getElementById("totp-overlay"),m=r.querySelector(".totp-card");if(!m)return;if(m.dataset.originalBody||(m.dataset.originalBody=m.innerHTML),!m.querySelector("#auth-gate-email-alt")){const e=document.createElement("div");e.id="auth-gate-email-alt",e.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",e.innerHTML=` + Or sign in with email instead \u2192 + `,m.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",o=>{o.preventDefault(),f(c)})}r.classList.add("show")}window.__dc_049_handled=!0;const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const y=s.get("return");if(y)try{if(new URL(y,window.location.origin).origin===window.location.origin)try{sessionStorage.setItem("totp_redirect",y)}catch{}}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(u,0)}window._showAuthGate=u,window._authGateMethods=n})(),(function(){function l(){const v=document.querySelector(".totp-card");if(!v)return;const y=getComputedStyle(v).backgroundColor.match(/\d+/g);if(!y)return;const c=(.299*+y[0]+.587*+y[1]+.114*+y[2])/255,r=v.querySelector(".totp-logo-dark"),m=v.querySelector(".totp-logo-light");r&&(r.style.display=c>.5?"none":""),m&&(m.style.display=c>.5?"":"none")}function n(){const v=document.getElementById("totp-overlay");if(v){v.classList.add("show"),setTimeout(l,50);const s=v.querySelector(".totp-digits input");s&&setTimeout(()=>s.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const v=document.getElementById("totp-overlay");v&&v.classList.remove("show")}const b=document.getElementById("totp-digits");if(b){const v=b.querySelectorAll("input");v.forEach((s,y)=>{s.addEventListener("input",c=>{const r=c.target.value.replace(/\D/g,"");c.target.value=r.slice(0,1),r&&yd.value).join("");m.length===6&&f(m)}),s.addEventListener("keydown",c=>{c.key==="Backspace"&&!c.target.value&&y>0&&(v[y-1].focus(),v[y-1].value="")}),s.addEventListener("paste",c=>{c.preventDefault();const r=(c.clipboardData.getData("text")||"").replace(/\D/g,"");r.length>=6&&(v.forEach((m,d)=>{m.value=r[d]||""}),v[5].focus(),f(r.slice(0,6)))})})}async function f(v){const s=document.getElementById("totp-error");s.textContent="Verifying...",s.className="totp-error verifying";try{const c=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:v})})).json();if(c.success){s.textContent="",c.csrfToken&&(csrfToken=c.csrfToken),g();const r=safeSessionGet("totp_redirect");if(r){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=r;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{s.textContent=c.error||"Invalid code",s.className="totp-error";const r=document.querySelectorAll("#totp-digits input");r.forEach(m=>{m.value=""}),r[0]?.focus()}}catch{s.textContent="Connection error",s.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const v=urlParams.get("return");if(v)try{const s=new URL(v,window.location.origin),y=s.hostname,c=s.origin===window.location.origin,r=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,m=y.endsWith(r)||y===r.substring(1);(c||m)&&safeSessionSet("totp_redirect",v)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=n})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function n(v){const s=document.getElementById("totp-recovery-link");s&&(s.style.display=v?"":"none")}function g(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="");const s=document.getElementById("totp-recovery-status"),y=document.getElementById("totp-recovery-import"),c=document.getElementById("totp-recovery-verify");y&&(y.style.display=""),c&&(c.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(r=>{s.textContent=r.hint||"",r.status==="healthy"?s.style.borderColor="var(--ok-fg, #7ef2ff)":r.status==="unreadable"?(s.style.borderColor="var(--bad-fg, #ff9aa3)",s.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):r.status==="not_configured"?s.style.borderColor="var(--muted)":s.style.borderColor="var(--border)"}),setTimeout(()=>{document.getElementById("totp-recovery-secret")?.focus()},100)}function b(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="none")}async function f(){const v=document.getElementById("totp-recovery-secret").value.trim(),s=document.getElementById("totp-recovery-error");if(s.textContent="",!v){s.textContent="Paste your Base32 key first";return}if(!/^[A-Za-z2-7\s]+=*$/.test(v)){s.textContent="Invalid Base32 format \u2014 should be letters A-Z and digits 2-7 only";return}try{const y=await fetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:v})}),c=await y.json();if(!y.ok||!c.success){s.textContent=c.error||c.message||"Restore failed";return}document.getElementById("totp-recovery-import").style.display="none",document.getElementById("totp-recovery-verify").style.display="",setTimeout(()=>document.getElementById("totp-recovery-code")?.focus(),100)}catch{s.textContent="Network error \u2014 try again"}}async function u(){const v=document.getElementById("totp-recovery-code").value.trim(),s=document.getElementById("totp-recovery-confirm-error");if(s.textContent="",!/^\d{6}$/.test(v)){s.textContent="Enter a 6-digit code";return}try{const y=await fetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:v})}),c=await y.json();if(!y.ok||!c.success){s.textContent=c.error||c.message||"Invalid code",document.getElementById("totp-recovery-code").value="",document.getElementById("totp-recovery-code")?.focus();return}b();const r=document.getElementById("totp-overlay");r&&r.classList.remove("show"),typeof window.initializeDashboard=="function"&&window.initializeDashboard()}catch{s.textContent="Network error \u2014 try again"}}document.getElementById("totp-show-recovery")?.addEventListener("click",v=>{v.preventDefault(),g()}),document.getElementById("totp-recovery-close")?.addEventListener("click",b),document.getElementById("totp-recovery-submit")?.addEventListener("click",f),document.getElementById("totp-recovery-confirm")?.addEventListener("click",u),document.getElementById("totp-recovery-secret")?.addEventListener("keydown",v=>{v.key==="Enter"&&(v.preventDefault(),f())}),document.getElementById("totp-recovery-code")?.addEventListener("keydown",v=>{v.key==="Enter"&&(v.preventDefault(),u())}),window._refreshRecoveryLink=async function(){const v=await l();return v&&v.success&&v.status&&v.status!=="healthy"?n(!0):n(!1),v}})(),(function(){const l=new ErrorHandler;injectModal("folder-browser-modal",`

\u{1F4C2} Browse for Media Folders

@@ -145,7 +179,7 @@
-
`);const y=document.getElementById("service-creds-modal");let p=null;const v=["sonarr","radarr","prowlarr","overseerr"],i=["sonarr","radarr"];function s(o){return o.externalUrl||o.url||""}function m(o){const l=document.getElementById("svc-creds-error");l.textContent=o,l.style.display=""}function t(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){p=o,t();const l=document.getElementById("svc-creds-title"),g=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),r=document.getElementById("svc-creds-apikey"),f=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");l.textContent=o.name+" Credentials";const u=!!o.isExternal,b=v.includes(o.id)||v.includes(o.appTemplate),d=i.includes(o.id)||i.includes(o.appTemplate);e.style.display=u?"":"none",r.style.display=b?"":"none",n.style.display=d?"":"none",f.style.display=u?"none":"";const E=document.getElementById("svc-quality-select");E.innerHTML='',document.getElementById("svc-quality-status").textContent="",u?(g.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):b?g.textContent="API key bypasses the app login screen automatically.":g.textContent="Credentials are injected automatically when accessing this service.",await h(o),y.classList.add("show")};async function h(o){const l=document.getElementById("svc-creds-dot"),g=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let r=!1;if(o.isExternal){try{const u=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();u.success?(document.getElementById("svc-seedhost-user").value=u.username||"",u.hasCredentials&&(r=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const u=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();u.success&&(u.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",r=!0):document.getElementById("svc-apikey-input").value="",u.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=u.username||"",r=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const f=o.id||o.appTemplate;if(i.includes(f)&&await c(o),r){l.style.background="var(--ok-fg, #74dfc4)",g.style.color="var(--ok-fg, #74dfc4)",g.textContent="Credentials stored",e.style.display="";const n=document.getElementById(`creds-btn-${o.id}`);n&&n.classList.add("has-creds")}else l.style.background="var(--muted)",g.style.color="var(--muted)",g.textContent="No credentials stored",e.style.display="none"}async function c(o){const l=document.getElementById("svc-quality-select"),g=document.getElementById("svc-quality-status"),e=o.id||o.appTemplate,r=s(o);if(!r){l.innerHTML='';return}l.innerHTML='',g.textContent="";try{const f=new URLSearchParams({service:e,url:r}),u=await(await fetch(`/api/v1/arr/quality-profiles?${f}`)).json();if(!u.success||!u.profiles?.length){l.innerHTML='';return}l.innerHTML="";for(const b of u.profiles){const d=document.createElement("option");d.value=b.id,d.textContent=b.name,l.appendChild(d)}if(u.storedProfileId&&(l.value=String(u.storedProfileId)),!l.value){const b=u.profiles.find(d=>/720/i.test(d.name));b&&(l.value=String(b.id))}!l.value&&u.profiles.length&&(l.value=String(u.profiles[0].id)),g.innerHTML=`${u.profiles.length} profiles loaded`}catch(f){l.innerHTML='',g.innerHTML=`Error: ${f.message}`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!p)return;const o=p.id||p.appTemplate,l=s(p),e=document.getElementById("svc-apikey-input")?.value.trim(),r=document.getElementById("svc-quality-select"),f=document.getElementById("svc-quality-status");if(!l){f.innerHTML='No service URL available';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){f.innerHTML='Enter an API key first';return}r.innerHTML='',f.textContent="";try{const n=new URLSearchParams({service:o,url:l,apiKey:e}),b=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!b.success){r.innerHTML='',f.innerHTML=`${b.error||"Failed to fetch profiles"}`;return}if(!b.profiles?.length){r.innerHTML='';return}r.innerHTML="";for(const E of b.profiles){const T=document.createElement("option");T.value=E.id,T.textContent=E.name,r.appendChild(T)}const d=b.profiles.find(E=>/720/i.test(E.name));d?r.value=String(d.id):b.profiles.length&&(r.value=String(b.profiles[0].id)),f.innerHTML=`${b.profiles.length} profiles loaded`}catch(n){r.innerHTML='',f.innerHTML=`${n.message}`}}),document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!p)return;const o=document.getElementById("svc-creds-save");o.textContent="Saving...",o.disabled=!0,t();try{const l=v.includes(p.id)||v.includes(p.appTemplate),g=p.id||p.appTemplate;if(p.isExternal){const f=document.getElementById("svc-seedhost-user").value.trim(),n=document.getElementById("svc-seedhost-pass").value;f&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:f,password:n||void 0,serviceId:p.id})})}const r=document.getElementById("svc-apikey-input")?.value.trim();if(r&&r!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")if(l){const f=s(p),n=document.getElementById("svc-quality-select"),u=n?.value?parseInt(n.value):void 0,b=n?.selectedOptions?.[0]?.textContent||void 0,E=await(await secureFetch("/api/v1/arr/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,apiKey:r,url:f||void 0,qualityProfileId:u||void 0,qualityProfileName:b||void 0})})).json();if(!E.success){m(E.error||"Failed to save API key"),o.textContent="Save",o.disabled=!1;return}E.connectionTest&&!E.connectionTest.success&&m(`API key saved but connection test failed: ${E.connectionTest.error}`)}else await secureFetch(`/api/v1/services/${p.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:r})});else if(l&&i.includes(g)){const f=document.getElementById("svc-quality-select"),n=f?.value?parseInt(f.value):void 0,u=f?.selectedOptions?.[0]?.textContent||void 0;n&&await secureFetch("/api/v1/arr/quality-profiles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,qualityProfileId:n,qualityProfileName:u})})}if(!p.isExternal){const f=document.getElementById("svc-basic-user").value.trim(),n=document.getElementById("svc-basic-pass").value;f&&n&&await secureFetch(`/api/v1/services/${p.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:f,password:n})})}await h(p)}catch(l){a.logError("[ServiceCredentials] Save",l,{function:"saveCredentials"}),m("Failed to save: "+(l.message||"Unknown error"))}o.textContent="Save",o.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(p&&confirm(`Remove stored credentials for ${p.name}?`)){t();try{const o=p.id||p.appTemplate,l=v.includes(o);p.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${p.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${p.id}/credentials`,{method:"DELETE"}),l&&await secureFetch(`/api/v1/arr/credentials/${o}`,{method:"DELETE"});const g=document.getElementById(`creds-btn-${p.id}`);g&&g.classList.remove("has-creds"),await h(p)}catch(o){a.logError("[ServiceCredentials] Clear",o,{function:"clearCredentials"}),m("Failed to clear: "+(o.message||"Unknown error"))}}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{y.classList.remove("show"),p=null}),y?.addEventListener("click",o=>{o.target===y&&(y.classList.remove("show"),p=null)}),window.refreshCredsButtons=async function(){try{for(const o of window.APPS||[]){if(!o.isExternal&&!o.appTemplate&&!o.url)continue;let l=!1;if(o.isExternal)try{const r=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();r.success&&r.hasCredentials&&(l=!0)}catch{}try{const r=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();r.success&&(r.hasApiKey||r.hasBasicAuth)&&(l=!0)}catch{}const g=document.getElementById(`creds-btn-${o.id}`);g&&g.classList.toggle("has-creds",l)}}catch{}}})(),(function(){const a=new ErrorHandler;injectModal("totp-settings-modal",`
+
`);const n=document.getElementById("service-creds-modal");let g=null;const b=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function u(r){return r.externalUrl||r.url||""}function v(r){const m=document.getElementById("svc-creds-error");m.textContent=r,m.style.display=""}function s(){const r=document.getElementById("svc-creds-error");r.textContent="",r.style.display="none"}window.openServiceCredsModal=async function(r){g=r,s();const m=document.getElementById("svc-creds-title"),d=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),o=document.getElementById("svc-creds-apikey"),i=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");m.textContent=r.name+" Credentials";const a=!!r.isExternal,h=b.includes(r.id)||b.includes(r.appTemplate),p=f.includes(r.id)||f.includes(r.appTemplate);e.style.display=a?"":"none",o.style.display=h?"":"none",t.style.display=p?"":"none",i.style.display=a?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='',document.getElementById("svc-quality-status").textContent="",a?(d.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${r.name}`):h?d.textContent="API key bypasses the app login screen automatically.":d.textContent="Credentials are injected automatically when accessing this service.",await y(r),n.classList.add("show")};async function y(r){const m=document.getElementById("svc-creds-dot"),d=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let o=!1;if(r.isExternal){try{const a=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();a.success?(document.getElementById("svc-seedhost-user").value=a.username||"",a.hasCredentials&&(o=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const a=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();a.success&&(a.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",o=!0):document.getElementById("svc-apikey-input").value="",a.hasBasicAuth&&!r.isExternal?(document.getElementById("svc-basic-user").value=a.username||"",o=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const i=r.id||r.appTemplate;if(f.includes(i)&&await c(r),o){m.style.background="var(--ok-fg, #74dfc4)",d.style.color="var(--ok-fg, #74dfc4)",d.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${r.id}`);t&&t.classList.add("has-creds")}else m.style.background="var(--muted)",d.style.color="var(--muted)",d.textContent="No credentials stored",e.style.display="none"}async function c(r){const m=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status"),e=r.id||r.appTemplate,o=u(r);if(!o){m.innerHTML='';return}m.innerHTML='',d.textContent="";try{const i=new URLSearchParams({service:e,url:o}),a=await(await fetch(`/api/v1/arr/quality-profiles?${i}`)).json();if(!a.success||!a.profiles?.length){m.innerHTML='';return}m.innerHTML="";for(const h of a.profiles){const p=document.createElement("option");p.value=h.id,p.textContent=h.name,m.appendChild(p)}if(a.storedProfileId&&(m.value=String(a.storedProfileId)),!m.value){const h=a.profiles.find(p=>/720/i.test(p.name));h&&(m.value=String(h.id))}!m.value&&a.profiles.length&&(m.value=String(a.profiles[0].id)),d.innerHTML=`${a.profiles.length} profiles loaded`}catch(i){m.innerHTML='',d.innerHTML=`Error: ${i.message}`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const r=g.id||g.appTemplate,m=u(g),e=document.getElementById("svc-apikey-input")?.value.trim(),o=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status");if(!m){i.innerHTML='No service URL available';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){i.innerHTML='Enter an API key first';return}o.innerHTML='',i.textContent="";try{const t=new URLSearchParams({service:r,url:m,apiKey:e}),h=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!h.success){o.innerHTML='',i.innerHTML=`${h.error||"Failed to fetch profiles"}`;return}if(!h.profiles?.length){o.innerHTML='';return}o.innerHTML="";for(const w of h.profiles){const T=document.createElement("option");T.value=w.id,T.textContent=w.name,o.appendChild(T)}const p=h.profiles.find(w=>/720/i.test(w.name));p?o.value=String(p.id):h.profiles.length&&(o.value=String(h.profiles[0].id)),i.innerHTML=`${h.profiles.length} profiles loaded`}catch(t){o.innerHTML='',i.innerHTML=`${t.message}`}}),document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!g)return;const r=document.getElementById("svc-creds-save");r.textContent="Saving...",r.disabled=!0,s();try{const m=b.includes(g.id)||b.includes(g.appTemplate),d=g.id||g.appTemplate;if(g.isExternal){const i=document.getElementById("svc-seedhost-user").value.trim(),t=document.getElementById("svc-seedhost-pass").value;i&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:i,password:t||void 0,serviceId:g.id})})}const o=document.getElementById("svc-apikey-input")?.value.trim();if(o&&o!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")if(m){const i=u(g),t=document.getElementById("svc-quality-select"),a=t?.value?parseInt(t.value):void 0,h=t?.selectedOptions?.[0]?.textContent||void 0,w=await(await secureFetch("/api/v1/arr/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:d,apiKey:o,url:i||void 0,qualityProfileId:a||void 0,qualityProfileName:h||void 0})})).json();if(!w.success){v(w.error||"Failed to save API key"),r.textContent="Save",r.disabled=!1;return}w.connectionTest&&!w.connectionTest.success&&v(`API key saved but connection test failed: ${w.connectionTest.error}`)}else await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:o})});else if(m&&f.includes(d)){const i=document.getElementById("svc-quality-select"),t=i?.value?parseInt(i.value):void 0,a=i?.selectedOptions?.[0]?.textContent||void 0;t&&await secureFetch("/api/v1/arr/quality-profiles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:d,qualityProfileId:t,qualityProfileName:a})})}if(!g.isExternal){const i=document.getElementById("svc-basic-user").value.trim(),t=document.getElementById("svc-basic-pass").value;i&&t&&await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:i,password:t})})}await y(g)}catch(m){l.logError("[ServiceCredentials] Save",m,{function:"saveCredentials"}),v("Failed to save: "+(m.message||"Unknown error"))}r.textContent="Save",r.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(g&&confirm(`Remove stored credentials for ${g.name}?`)){s();try{const r=g.id||g.appTemplate,m=b.includes(r);g.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${g.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"DELETE"}),m&&await secureFetch(`/api/v1/arr/credentials/${r}`,{method:"DELETE"});const d=document.getElementById(`creds-btn-${g.id}`);d&&d.classList.remove("has-creds"),await y(g)}catch(r){l.logError("[ServiceCredentials] Clear",r,{function:"clearCredentials"}),v("Failed to clear: "+(r.message||"Unknown error"))}}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{n.classList.remove("show"),g=null}),n?.addEventListener("click",r=>{r.target===n&&(n.classList.remove("show"),g=null)}),window.refreshCredsButtons=async function(){try{for(const r of window.APPS||[]){if(!r.isExternal&&!r.appTemplate&&!r.url)continue;let m=!1;if(r.isExternal)try{const o=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();o.success&&o.hasCredentials&&(m=!0)}catch{}try{const o=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();o.success&&(o.hasApiKey||o.hasBasicAuth)&&(m=!0)}catch{}const d=document.getElementById(`creds-btn-${r.id}`);d&&d.classList.toggle("has-creds",m)}}catch{}}})(),(function(){const l=new ErrorHandler;injectModal("totp-settings-modal",`

Authentication Settings

@@ -254,7 +288,7 @@
- `);async function y(){try{const s=await(await fetch("/api/v1/totp/config")).json();if(!s.success)return;const{enabled:m,sessionDuration:t,isSetUp:h}=s.config,c=document.getElementById("totp-status-dot"),o=document.getElementById("totp-status-text"),l=document.getElementById("totp-status-banner"),g=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),r=document.getElementById("totp-duration-section"),f=document.getElementById("totp-disable-section");if(m&&h){c.style.background="var(--ok-fg, #7ef2ff)",l.style.borderColor="var(--ok-fg, #7ef2ff)",l.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",o.textContent="TOTP is active",o.style.color="var(--ok-fg, #7ef2ff)",g.style.display="block";const n=document.getElementById("totp-setup-btn");n&&(n.textContent="Generate New Secret"),e.style.display="none",r.style.display="block",f.style.display="block",document.getElementById("totp-duration-select").value=t}else c.style.background="var(--muted)",l.style.borderColor="var(--border)",l.style.background="transparent",o.textContent="TOTP is not configured",o.style.color="var(--muted)",g.style.display="block",e.style.display="none",r.style.display="none",f.style.display="none";v(m&&h,t)}catch(i){console.warn("Failed to load TOTP settings:",i)}}const p={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function v(i,s){const m=document.getElementById("auth-card"),t=document.getElementById("auth-pill"),h=document.getElementById("auth-dot"),c=document.getElementById("auth-status-text");m&&(i?(m.setAttribute("data-status","on"),t.className="badge on",t.textContent="YES",h.className="dot ok at-bl",c.textContent="Session: "+(p[s]||s)):(m.setAttribute("data-status","off"),t.className="badge off",t.textContent="NO",h.className="dot bad at-bl",c.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();s.success&&(document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(i){a.logError("[TOTP] Setup Failed",i,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const i=document.getElementById("totp-import-key").value.trim(),s=document.getElementById("totp-import-error");if(s.textContent="",!i){s.textContent="Paste a Base32 secret key first";return}try{const t=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:i})})).json();t.success?(s.textContent="",document.getElementById("totp-qr-image").src=t.qrCode,document.getElementById("totp-manual-key").textContent=t.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):s.textContent=t.error||t.message||"Import failed"}catch{s.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const i=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(i).then(()=>{const s=document.getElementById("totp-copy-key");s.textContent="\u2705",setTimeout(()=>{s.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const i=document.getElementById("totp-manual-key").textContent.trim();if(!i)return;const s={service:"DashCaddy",type:"totp-secret",secret:i,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},m=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),t=URL.createObjectURL(m),h=document.createElement("a");h.href=t,h.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(h),h.click(),document.body.removeChild(h),URL.revokeObjectURL(t);const c=document.getElementById("totp-download-backup");c.textContent="\u2705 Saved",setTimeout(()=>{c.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const i=document.getElementById("totp-setup-code").value,s=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(i)){s.textContent="Enter a 6-digit code";return}try{const t=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:i})})).json();t.success?(s.textContent="",y()):s.textContent=t.error||"Invalid code"}catch{s.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",i=>{i.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async i=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:i.target.value})}),y()}catch(s){a.logError("[TOTP] Update Session Duration",s,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"})).json()).success&&y()}catch(i){a.logError("[TOTP] Disable Failed",i,{function:"disableTOTP"})}}),document.getElementById("auth-settings-btn")?.addEventListener("click",()=>{y(),openModal("totp-settings-modal")}),document.getElementById("totp-modal-close")?.addEventListener("click",()=>{closeModal("totp-settings-modal")}),document.getElementById("totp-settings-modal")?.addEventListener("click",i=>{i.target.id==="totp-settings-modal"&&closeModal("totp-settings-modal")}),window._updateAuthCard=v,(async()=>{try{const s=await(await fetch("/api/v1/totp/config")).json();if(s.success){const m=s.config.enabled&&s.config.isSetUp;v(m,s.config.sessionDuration)}}catch(i){a.logError("[TOTP] AuthCard Update",i,{function:"authCardUpdate"})}})()})(),(function(){injectModal("token-management-modal",` + `);async function n(){try{const u=await(await fetch("/api/v1/totp/config")).json();if(!u.success)return;const{enabled:v,sessionDuration:s,isSetUp:y}=u.config,c=document.getElementById("totp-status-dot"),r=document.getElementById("totp-status-text"),m=document.getElementById("totp-status-banner"),d=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),o=document.getElementById("totp-duration-section"),i=document.getElementById("totp-disable-section");if(v&&y){c.style.background="var(--ok-fg, #7ef2ff)",m.style.borderColor="var(--ok-fg, #7ef2ff)",m.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",r.textContent="TOTP is active",r.style.color="var(--ok-fg, #7ef2ff)",d.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",o.style.display="block",i.style.display="block",document.getElementById("totp-duration-select").value=s}else c.style.background="var(--muted)",m.style.borderColor="var(--border)",m.style.background="transparent",r.textContent="TOTP is not configured",r.style.color="var(--muted)",d.style.display="block",e.style.display="none",o.style.display="none",i.style.display="none";b(v&&y,s)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function b(f,u){const v=document.getElementById("auth-card"),s=document.getElementById("auth-pill"),y=document.getElementById("auth-dot"),c=document.getElementById("auth-status-text");v&&(f?(v.setAttribute("data-status","on"),s.className="badge on",s.textContent="YES",y.className="dot ok at-bl",c.textContent="Session: "+(g[u]||u)):(v.setAttribute("data-status","off"),s.className="badge off",s.textContent="NO",y.className="dot bad at-bl",c.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const u=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();u.success&&(document.getElementById("totp-qr-image").src=u.qrCode,document.getElementById("totp-manual-key").textContent=u.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){l.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),u=document.getElementById("totp-import-error");if(u.textContent="",!f){u.textContent="Paste a Base32 secret key first";return}try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();s.success?(u.textContent="",document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):u.textContent=s.error||s.message||"Import failed"}catch{u.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const u=document.getElementById("totp-copy-key");u.textContent="\u2705",setTimeout(()=>{u.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const u={service:"DashCaddy",type:"totp-secret",secret:f,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},v=new Blob([JSON.stringify(u,null,2)],{type:"application/json"}),s=URL.createObjectURL(v),y=document.createElement("a");y.href=s,y.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(y),y.click(),document.body.removeChild(y),URL.revokeObjectURL(s);const c=document.getElementById("totp-download-backup");c.textContent="\u2705 Saved",setTimeout(()=>{c.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=document.getElementById("totp-setup-code").value,u=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(f)){u.textContent="Enter a 6-digit code";return}try{const s=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();s.success?(u.textContent="",n()):u.textContent=s.error||"Invalid code"}catch{u.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",f=>{f.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async f=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:f.target.value})}),n()}catch(u){l.logError("[TOTP] Update Session Duration",u,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"})).json()).success&&n()}catch(f){l.logError("[TOTP] Disable Failed",f,{function:"disableTOTP"})}}),document.getElementById("auth-settings-btn")?.addEventListener("click",()=>{n(),openModal("totp-settings-modal")}),document.getElementById("totp-modal-close")?.addEventListener("click",()=>{closeModal("totp-settings-modal")}),document.getElementById("totp-settings-modal")?.addEventListener("click",f=>{f.target.id==="totp-settings-modal"&&closeModal("totp-settings-modal")}),window._updateAuthCard=b,(async()=>{try{const u=await(await fetch("/api/v1/totp/config")).json();if(u.success){const v=u.config.enabled&&u.config.isSetUp;b(v,u.config.sessionDuration)}}catch(f){l.logError("[TOTP] AuthCard Update",f,{function:"authCardUpdate"})}})()})(),(function(){"use strict";const l={me:"/api/v1/auth/me",users:"/api/v1/auth/admin/users",allowlist:"/api/v1/auth/admin/allowlist",invites:"/api/v1/auth/admin/invites"};function n(d,e,...o){const i=document.createElement(d);if(e)for(const t of Object.keys(e)){const a=e[t];a==null||a===!1||(t==="class"?i.className=a:t==="text"?i.textContent=a:t==="html"?i.innerHTML=a:t.startsWith("on")&&typeof a=="function"?i.addEventListener(t.slice(2).toLowerCase(),a):i.setAttribute(t,a))}for(const t of o)t==null||t===!1||(typeof t=="string"?i.appendChild(document.createTextNode(t)):i.appendChild(t));return i}async function g(d,e){const o=window.SITE&&window.SITE.csrfToken||"";e=e||{},e.headers=Object.assign({"Content-Type":"application/json"},e.headers||{},o?{"X-CSRF-Token":o}:{}),e.body&&typeof e.body!="string"&&(e.body=JSON.stringify(e.body));const i=await fetch(d,e),t=await i.json().catch(()=>({}));if(!i.ok){const a=t&&(t.message||t.error)||"HTTP "+i.status,h=new Error(a);throw h.status=i.status,h}return t}function b(d){const e={admin:"background:#7c3aed;color:#fff",operator:"background:#2563eb;color:#fff",viewer:"background:#6b7280;color:#fff"};return n("span",{class:"role-badge",style:"display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;"+(e[d]||e.viewer),text:d})}function f(d,e,o){if(d.innerHTML="",!e||e.length===0){d.appendChild(n("p",{style:"color:var(--muted)",text:"No users yet."}));return}const i=n("table",{style:"width:100%;border-collapse:collapse;font-size:0.9rem"});i.appendChild(n("thead",null,n("tr",{style:"border-bottom:1px solid var(--border)"},n("th",{style:"text-align:left;padding:8px",text:"Email"}),n("th",{style:"text-align:left;padding:8px",text:"Role"}),n("th",{style:"text-align:left;padding:8px",text:"Created"}),n("th",{style:"text-align:left;padding:8px",text:"Last login"}),n("th",{style:"text-align:right;padding:8px",text:"Actions"}))));const t=n("tbody");for(const a of e){const h=n("tr",{style:"border-bottom:1px solid var(--border)"}),p=n("td",{style:"padding:8px"});p.appendChild(n("span",{text:a.email||"(no email)"})),a.displayName&&a.displayName!==(a.email||"").split("@")[0]&&(p.appendChild(n("br")),p.appendChild(n("small",{style:"color:var(--muted)",text:a.displayName}))),h.appendChild(p);const w=n("td",{style:"padding:8px"});w.appendChild(b(a.role)),h.appendChild(w),h.appendChild(n("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:a.createdAt?new Date(a.createdAt).toLocaleDateString():"\u2014"})),h.appendChild(n("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:a.lastLoginAt?new Date(a.lastLoginAt).toLocaleString():"\u2014"}));const T=n("td",{style:"padding:8px;text-align:right"}),L=n("select",{style:"padding:2px 6px;margin-right:6px",onchange:async E=>{try{await g(l.users+"/"+encodeURIComponent(a.id),{method:"PATCH",body:{role:E.target.value}}),o&&o()}catch(B){window.errorHandler&&window.errorHandler.show("Role update failed: "+B.message),E.target.value=a.role}}});for(const E of["admin","operator","viewer"]){const B=n("option",{value:E,text:E});E===a.role&&(B.selected=!0),L.appendChild(B)}T.appendChild(L);const P=n("button",{class:"btn-sm",style:"padding:2px 8px",text:"Delete",onclick:async()=>{if(confirm("Delete user "+(a.email||a.id)+"? This cannot be undone."))try{await g(l.users+"/"+encodeURIComponent(a.id),{method:"DELETE"}),o&&o()}catch(E){window.errorHandler&&window.errorHandler.show("Delete failed: "+E.message)}}});T.appendChild(P),h.appendChild(T),t.appendChild(h)}i.appendChild(t),d.appendChild(i)}function u(d,e){const o=n("form",{style:"display:flex;gap:8px;flex-wrap:wrap;align-items:end",onsubmit:async t=>{t.preventDefault();const a=new FormData(t.target),h={email:a.get("email"),role:a.get("role"),ttlHours:parseInt(a.get("ttlHours"),10)||24,sendEmail:a.get("sendEmail")==="on"};try{const p=await g(l.invites,{method:"POST",body:h});t.target.reset(),e&&e(p)}catch(p){window.errorHandler&&window.errorHandler.show("Invite failed: "+p.message)}}});o.appendChild(n("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},n("span",{text:"Email"}),n("input",{name:"email",type:"email",required:!0,placeholder:"user@example.com",style:"padding:6px"})));const i=n("select",{name:"role",style:"padding:6px"});for(const t of["operator","viewer","admin"])i.appendChild(n("option",{value:t,text:t}));o.appendChild(n("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},n("span",{text:"Role"}),i)),o.appendChild(n("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},n("span",{text:"TTL (hours)"}),n("input",{name:"ttlHours",type:"number",min:"1",max:"168",value:"24",style:"padding:6px;width:80px"}))),o.appendChild(n("label",{style:"display:flex;gap:4px;align-items:center;font-size:0.85rem"},n("input",{name:"sendEmail",type:"checkbox",checked:!0}),n("span",{text:"Send email"}))),o.appendChild(n("button",{type:"submit",class:"btn-sm",style:"padding:6px 12px",text:"Issue invite"})),d.appendChild(o)}function v(d,e,o){if(d.innerHTML="",!e||e.length===0){d.appendChild(n("p",{style:"color:var(--muted)",text:"No outstanding invites."}));return}const i=n("table",{style:"width:100%;border-collapse:collapse;font-size:0.9rem"});i.appendChild(n("thead",null,n("tr",{style:"border-bottom:1px solid var(--border)"},n("th",{style:"text-align:left;padding:8px",text:"Email"}),n("th",{style:"text-align:left;padding:8px",text:"Role"}),n("th",{style:"text-align:left;padding:8px",text:"Invited by"}),n("th",{style:"text-align:left;padding:8px",text:"Expires"}),n("th",{style:"text-align:right;padding:8px",text:"Actions"}))));const t=n("tbody");for(const a of e){const h=n("tr",{style:"border-bottom:1px solid var(--border)"});h.appendChild(n("td",{style:"padding:8px",text:a.email})),h.appendChild(n("td",{style:"padding:8px"},b(a.role))),h.appendChild(n("td",{style:"padding:8px;color:var(--muted)",text:a.invitedBy||"\u2014"})),h.appendChild(n("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:a.expiresAt?new Date(a.expiresAt).toLocaleString():"\u2014"}));const p=n("td",{style:"padding:8px;text-align:right"});p.appendChild(n("button",{class:"btn-sm",style:"padding:2px 8px",text:"Revoke",onclick:async()=>{if(confirm("Revoke invite for "+a.email+"?"))try{await g(l.invites+"/"+encodeURIComponent(a.id),{method:"DELETE"}),o&&o()}catch(w){window.errorHandler&&window.errorHandler.show("Revoke failed: "+w.message)}}})),h.appendChild(p),t.appendChild(h)}i.appendChild(t),d.appendChild(i)}function s(d,e){const o=n("div",{style:"margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem"});o.appendChild(n("strong",{text:"Invite issued \u2014 copy the link below. It will not be shown again."})),o.appendChild(n("br")),o.appendChild(n("code",{style:"display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5",text:d.acceptUrl}));const i=n("button",{class:"btn-sm",style:"margin-top:8px;padding:4px 10px",text:"Copy link",onclick:async()=>{try{await navigator.clipboard.writeText(d.acceptUrl),i.textContent="Copied!",setTimeout(()=>{i.textContent="Copy link"},2e3)}catch{window.errorHandler&&window.errorHandler.show("Clipboard blocked: select the link manually.")}}});o.appendChild(i),d.deliveredVia==="dev-console"?o.appendChild(n("p",{style:"margin-top:8px;color:#fbbf24;font-size:0.8rem",text:"SMTP not configured \u2014 the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK])."})):d.deliveredVia==="email"&&o.appendChild(n("p",{style:"margin-top:8px;color:#86efac;font-size:0.8rem",text:"Email sent to "+d.email+"."})),e.appendChild(o)}async function y(d){d.innerHTML="",d.appendChild(n("h2",{style:"margin:0 0 16px",text:"Admin \xB7 Users & Invites"}));const e=await g(l.me).catch(()=>({}));if(!e||!e.user||e.user.role!=="admin"){d.appendChild(n("p",{style:"color:var(--muted)",text:"Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me."}));return}const o=n("button",{class:"btn-sm",style:"float:right;padding:4px 10px",text:"Refresh",onclick:()=>y(d)});d.appendChild(o);const i=n("h3",{style:"margin:24px 0 8px;clear:both",text:"Users"});d.appendChild(i);const t=n("div",{id:"admin-users-list"});d.appendChild(t);const a=await g(l.users).catch(()=>({users:[]}));f(t,a.users,()=>y(d)),d.appendChild(n("h4",{style:"margin:24px 0 8px;font-size:0.95rem",text:"Pre-authorize email"}));const h=n("form",{style:"display:flex;gap:8px;align-items:end",onsubmit:async L=>{L.preventDefault();const P=L.target.email.value.trim();if(P)try{await g(l.users,{method:"POST",body:{email:P}}),L.target.reset(),y(d)}catch(E){window.errorHandler&&window.errorHandler.show("Add failed: "+E.message)}}});h.appendChild(n("input",{name:"email",type:"email",required:!0,placeholder:"user@example.com",style:"padding:6px"})),h.appendChild(n("button",{type:"submit",class:"btn-sm",style:"padding:6px 12px",text:"Add to allowlist"})),d.appendChild(h),d.appendChild(n("h3",{style:"margin:24px 0 8px",text:"Issue invite"}));const p=n("div");d.appendChild(p);const w=n("div",{id:"admin-invites-list",style:"margin-top:16px"});d.appendChild(w);const T=await g(l.invites).catch(()=>({invites:[]}));v(w,T.invites,()=>y(d)),u(p,L=>{s(L,p),y(d)})}async function c(){if(document.getElementById("admin-panel-root"))return;const e=n("div",{id:"admin-panel-root",style:"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;",onclick:t=>{t.target===e&&r()}}),o=n("div",{style:"background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)"});o.appendChild(n("button",{class:"btn-sm",style:"position:absolute;top:12px;right:12px;padding:4px 10px",text:"Close",onclick:r}));const i=n("div",{id:"admin-panel-body"});o.appendChild(i),e.appendChild(o),document.body.appendChild(e);try{await y(i)}catch(t){i.innerHTML='

Failed to load admin panel: '+(t.message||t)+"

"}}function r(){const d=document.getElementById("admin-panel-root");d&&d.remove()}async function m(d){async function e(){const o=await g(l.me).catch(()=>({})),i=document.getElementById("admin-trigger-btn");if(o&&o.user&&o.user.role==="admin"){if(i)return;const t=n("button",{id:"admin-trigger-btn",class:"btn-sm",style:"margin-left:8px;padding:6px 12px",text:"Admin",onclick:c});d?d.appendChild(t):document.body&&document.body.appendChild(t)}else i&&i.remove()}await e(),setInterval(e,6e4)}window.AdminPanel={open:c,close:r,attachTrigger:m}})(),(function(){injectModal("token-management-modal",`

\u{1F511} DNS Credentials

@@ -272,40 +306,40 @@
- `);function a(){return Object.keys(SITE.dnsServers||{})}function y(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function p(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const u=a();if(u.length===0){n.innerHTML='

No DNS servers configured.

';return}for(const b of u)n.insertAdjacentHTML("beforeend",` + `);function l(){return Object.keys(SITE.dnsServers||{})}function n(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const a=l();if(a.length===0){t.innerHTML='

No DNS servers configured.

';return}for(const h of a)t.insertAdjacentHTML("beforeend",`
-

${y(b)}

+

${n(h)}

- - + +
- - + +
- - + +
- - + +
-
+
- `)}function v(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const u=safeGet("dashcaddy-encryption-key");if(u)return safeSessionSet("dashcaddy-encryption-key",u),safeRemove("dashcaddy-encryption-key"),u;const b=new Uint8Array(32);return crypto.getRandomValues(b),n=Array.from(b,d=>d.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const i=v();function s(n,u){if(!n)return"";const b=crypto.getRandomValues(new Uint8Array(8)),d=Array.from(b,L=>L.toString(16).padStart(2,"0")).join(""),E=new TextEncoder().encode(u+d);let T="";for(let L=0;LparseInt($,16))),P=atob(n.substring(17)),C=new TextEncoder().encode(u+T);let B="";for(let $=0;${["readonly","admin"].forEach(u=>{["token","username"].forEach(b=>{safeRemove(`${n}-${u}-${b}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function f(n){const u=c(n,"readonly"),b=o(n,"readonly"),d=c(n,"admin"),E=o(n,"admin"),T=m(safeGet(`${n}-token-enc`),i),L=m(safeGet(`${n}-username-enc`),i);return{username:E||b||L,token:d||u||T,readonlyToken:u||T,readonlyUsername:b||L,adminToken:d||T,adminUsername:E||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{p();const n=document.getElementById("token-management-modal"),u=e();a().forEach(b=>{const d=u[b];document.getElementById(`${b}-readonly-username`).value=d.readonly.username,document.getElementById(`${b}-readonly-token`).value=d.readonly.token,document.getElementById(`${b}-admin-username`).value=d.admin.username,document.getElementById(`${b}-admin-token`).value=d.admin.token,document.getElementById(`${b}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const u=n.target.closest(".token-toggle");if(u){const b=u.dataset.target,d=document.getElementById(b);d.type==="password"?(d.type="text",u.textContent="\u{1F648}"):(d.type="password",u.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=a();n.forEach(d=>{g(d,"readonly",document.getElementById(`${d}-readonly-username`).value.trim()),l(d,"readonly",document.getElementById(`${d}-readonly-token`).value.trim()),g(d,"admin",document.getElementById(`${d}-admin-username`).value.trim()),l(d,"admin",document.getElementById(`${d}-admin-token`).value.trim())});const u={};let b=!1;if(n.forEach(d=>{const E={},T=document.getElementById(`${d}-readonly-username`).value.trim(),L=document.getElementById(`${d}-readonly-token`).value.trim(),P=document.getElementById(`${d}-admin-username`).value.trim(),C=document.getElementById(`${d}-admin-token`).value.trim();T&&L&&(E.readonly={username:T,password:L},b=!0),P&&C&&(E.admin={username:P,password:C},b=!0),Object.keys(E).length>0&&(u[d]=E)}),b){n.forEach(d=>{u[d]&&(document.getElementById(`${d}-token-status`).textContent="Verifying...",document.getElementById(`${d}-token-status`).className="token-status")});try{const E=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:u})})).json();E.results?n.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!u[T]){L.textContent="";return}const P=E.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):E.success?n.forEach(T=>{u[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):n.forEach(T=>{u[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(E.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(d){console.error("Failed to sync DNS credentials to backend:",d),n.forEach(E=>{u[E]&&(document.getElementById(`${E}-token-status`).textContent="\u2713 Saved locally (sync failed)",document.getElementById(`${E}-token-status`).className="token-status")})}}else n.forEach(d=>{document.getElementById(`${d}-token-status`).textContent=""});setTimeout(()=>{n.every(E=>{const T=document.getElementById(`${E}-token-status`)?.textContent;return!T||T.includes("\u2713")})&&closeModal("token-management-modal")},1500)}),document.getElementById("token-cancel")?.addEventListener("click",()=>{closeModal("token-management-modal")}),document.getElementById("token-clear-all")?.addEventListener("click",async()=>{if(confirm("Clear all stored DNS credentials? This cannot be undone.")){r(),a().forEach(n=>{document.getElementById(`${n}-readonly-username`).value="",document.getElementById(`${n}-readonly-token`).value="",document.getElementById(`${n}-admin-username`).value="",document.getElementById(`${n}-admin-token`).value="",document.getElementById(`${n}-token-status`).textContent="\u2713 Cleared",document.getElementById(`${n}-token-status`).className="token-status success"});try{await secureFetch("/api/v1/dns/credentials",{method:"DELETE"})}catch{}}}),window.getToken=c,window.getUsername=o,window.setToken=l,window.setUsername=g,window.getAllCredentials=e,window.getCredential=t,window.setCredential=h,window.getEncryptionKey=v,window.getDnsIds=a,window.getDnsDisplayName=y})(),(function(){function a(l,g,e=null){const r=document.getElementById(l+"-dot"),f=document.getElementById(l+"-pill"),n=document.getElementById(l+"-time"),u=document.querySelector(`[data-app="${l}"]`);r&&(r.classList.toggle("ok",g),r.classList.toggle("bad",!g)),f&&(f.textContent=g?"ON":"OFF",f.classList.toggle("on",g),f.classList.toggle("off",!g)),n&&e!==null&&(n.textContent=g?`${e}ms`:"timeout",n.className=`response-time ${y(e,g)}`),u&&u.setAttribute("data-status",g?"on":"off")}function y(l,g){return g?l<200?"excellent":l<500?"good":l<1e3?"fair":"slow":"timeout"}async function p(l){const g=performance.now();try{const e=await fetch("/probe/"+l,{cache:"no-store"}),r=performance.now(),f=Math.round(r-g);return{isUp:e.status>=200&&e.status<400||e.status===401||e.status===403,responseTime:f}}catch{const e=performance.now();return{isUp:!1,responseTime:Math.round(e-g)}}}window.APPS=[];let v=null,i=!1;async function s(){try{window.SkeletonLoader&&window.SkeletonLoader.show(6);const l=await fetch("/api/v1/services",{cache:"no-store"});if(l.ok){const g=await l.json();window.APPS=g.services||[],window.SkeletonLoader&&window.SkeletonLoader.hide()}else console.error("Failed to load services:",l.status),window.SkeletonLoader&&window.SkeletonLoader.hide()}catch(l){console.error("Failed to load services:",l),window.SkeletonLoader&&window.SkeletonLoader.hide()}}function m(l){const g=window.APPS?.find(r=>r.id===l);if(g?.url)return g.url.startsWith("http")?g.url:"https://"+g.url;if(g?.isExternal&&g.externalUrl)return g.externalUrl;const e=SITE.dnsServers?.[l];return e?"http://"+e.ip+":"+(e.port||5380):buildServiceUrl(l)}function t(l,g,e){const r=document.createElement(l);return g&&(r.className=g),e&&(r.textContent=e),r}function h(){const l=document.getElementById("cards");l.innerHTML="";for(let g=0;g{O.stopPropagation(),window.openContainerLogsModal(e.containerId,e.name)},w.appendChild(k);const S=t("button","update-btn","\u2B06\uFE0F");S.title="Update container to latest version",S.id=`update-btn-${e.id}`,S.onclick=O=>{O.stopPropagation(),window.updateContainer(e.containerId,e.name,e.id)},w.appendChild(S);const A=t("button","exec-btn",">_");A.title="Open terminal",A.onclick=O=>{O.stopPropagation(),window.openExecModal&&window.openExecModal(e.containerId,e.name)},w.appendChild(A)}if(e.logPath&&!e.containerId){const k=t("button","logs-btn","\u{1F4CB}");k.title="View application logs",k.onclick=S=>{S.stopPropagation(),window.openFileLogsModal(e.logPath,e.name)},w.appendChild(k)}if(e.isExternal||e.appTemplate||e.url){const k=t("button","creds-btn","\u{1F511}");k.title="Auto-login credentials",k.id=`creds-btn-${e.id}`,k.onclick=S=>{S.stopPropagation(),window.openServiceCredsModal(e)},w.appendChild(k)}if(e.id!=="internet"){const k=t("button","options-btn","\u2699\uFE0F");k.title="Edit service settings",k.onclick=S=>{S.stopPropagation(),window.openServiceEditModal(e)},w.appendChild(k)}if(e.id!=="internet"){const k=t("button","delete-btn","\u{1F5D1}\uFE0F");k.title="Delete this service",k.onclick=S=>{S.stopPropagation(),window.deleteService(e.id,e.name)},w.appendChild(k)}const I=t("button",null,"Open");I.onclick=()=>window.open(m(e.id),"_blank","noopener"),w.appendChild(I),r.appendChild(w),r.style.transitionDelay=`${Math.min(g*45,270)}ms`,l.appendChild(r)}requestAnimationFrame(()=>{l.querySelectorAll(".card").forEach(g=>g.classList.add("loaded"))}),window.groupRecipeCards&&requestAnimationFrame(()=>window.groupRecipeCards()),window.refreshServiceFilter&&window.refreshServiceFilter()}function c(l,g,e=null){const r=document.getElementById("dot-"+l+"-grid"),f=document.getElementById("badge-"+l),n=document.getElementById("time-"+l),u=document.querySelector(`[data-app="${l}"]`);r&&(r.classList.toggle("ok",g),r.classList.toggle("bad",!g)),f&&(f.textContent=g?"ON":"OFF",f.classList.toggle("on",g),f.classList.toggle("off",!g)),n&&e!==null&&(n.textContent=g?`${e}ms`:"timeout",n.className=`response-time ${y(e,g)}`),u&&u.setAttribute("data-status",g?"on":"off")}async function o(){if(v)return i=!0,v;function l(r,f=new Date){const n=document.getElementById("stamp");n&&(n.textContent=`${r}: ${new Date(f).toLocaleTimeString()}`)}function g(r){Object.keys(SITE.dnsServers).forEach(n=>{const u=r[n];u&&a(n,u.isUp,u.responseTime)}),r.internet&&a("internet",r.internet.isUp,r.internet.responseTime),window.APPS.forEach(n=>{const u=r[n.id];u&&c(n.id,u.isUp,u.responseTime)})}async function e(){const r=Object.keys(SITE.dnsServers),f=r.map(d=>p(d));f.push(p("internet"));const n=await Promise.all(f);r.forEach((d,E)=>a(d,n[E].isUp,n[E].responseTime));const u=n[n.length-1];a("internet",u.isUp,u.responseTime),(await Promise.all(window.APPS.map(async d=>{const E=await p(d.id);return{id:d.id,...E}}))).forEach(d=>{c(d.id,d.isUp,d.responseTime)})}return v=(async()=>{try{const r=await fetch("/api/v1/services/status",{cache:"no-store"});if(!r.ok)throw new Error(`Status refresh failed (${r.status})`);const f=await r.json();g(f.statuses||{}),l("last check",f.checkedAt||new Date)}catch(r){console.warn("Batched status refresh failed, falling back to direct probes:",r);try{await e(),l("last check")}catch(f){console.error("Dashboard refresh failed:",f),l("last failed")}}finally{v=null,i&&(i=!1,setTimeout(()=>{window.refreshAll()},0))}})(),v}document.querySelector(".top")?.addEventListener("click",l=>{const g=l.target.closest('[id$="-open"]');if(!g)return;const e=g.id.replace("-open","");SITE.dnsServers[e]&&window.open(m(e),"_blank","noopener")}),document.getElementById("ca-open")?.addEventListener("click",()=>window.open(m("ca"),"_blank","noopener")),document.getElementById("creds-btn-ca")?.addEventListener("click",l=>{l.stopPropagation();const g=window.APPS.find(e=>e.id==="ca");g&&window.openServiceCredsModal&&window.openServiceCredsModal(g)}),document.getElementById("options-btn-ca")?.addEventListener("click",l=>{l.stopPropagation();const g=window.APPS.find(e=>e.id==="ca");g&&window.openServiceEditModal&&window.openServiceEditModal(g)}),document.getElementById("delete-btn-ca")?.addEventListener("click",l=>{l.stopPropagation(),window.deleteService&&window.deleteService("ca","DashCA")}),window.loadServices=s,window.buildGrid=h,window.refreshAll=o,window.setQuick=a,window.setBadge=c,window.getResponseTimeClass=y,window.checkServiceWithTiming=p,window.serviceUrl=m,window.el=t})(),(function(){async function a(t){const c=await(await secureFetch(`/api/v1/dns/restart/${t}`,{method:"POST"})).json();if(!c.success)throw new Error(c.error||"Restart failed");return c}document.querySelector(".top")?.addEventListener("click",async t=>{const h=t.target.closest('[id$="-restart"]');if(!h)return;const c=h.id.replace("-restart","");if(SITE.dnsServers[c]&&confirm(`Restart ${c.toUpperCase()} service?`))try{await withButton(h,"...",()=>a(c)),setTimeout(window.refreshAll,DC.DELAYS.RELOAD)}catch(o){showNotification("Restart failed: "+o.message,"error")}});async function y(t,h){const c=document.getElementById(`${t}-update`),o=c?.textContent||"\u2B06\uFE0F";try{c.textContent="\u{1F50D}",c.disabled=!0,c.title="Checking for updates...";const g=await(await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(h)}`)).json();if(!g.success)throw new Error(g.error||"Failed to check for updates");if(!g.updateAvailable){c.textContent="\u2705",c.title=`Already on latest version (${g.currentVersion})`,showNotification(`${t.toUpperCase()} is already up to date! Current version: ${g.currentVersion}`,"info"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server"},3e3);return}if(!confirm(`Update available for ${t.toUpperCase()}! + `)}function b(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const a=safeGet("dashcaddy-encryption-key");if(a)return safeSessionSet("dashcaddy-encryption-key",a),safeRemove("dashcaddy-encryption-key"),a;const h=new Uint8Array(32);return crypto.getRandomValues(h),t=Array.from(h,p=>p.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=b();function u(t,a){if(!t)return"";const h=crypto.getRandomValues(new Uint8Array(8)),p=Array.from(h,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(a+p);let T="";for(let L=0;LparseInt($,16))),P=atob(t.substring(17)),E=new TextEncoder().encode(a+T);let B="";for(let $=0;${["readonly","admin"].forEach(a=>{["token","username"].forEach(h=>{safeRemove(`${t}-${a}-${h}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function i(t){const a=c(t,"readonly"),h=r(t,"readonly"),p=c(t,"admin"),w=r(t,"admin"),T=v(safeGet(`${t}-token-enc`),f),L=v(safeGet(`${t}-username-enc`),f);return{username:w||h||L,token:p||a||T,readonlyToken:a||T,readonlyUsername:h||L,adminToken:p||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),a=e();l().forEach(h=>{const p=a[h];document.getElementById(`${h}-readonly-username`).value=p.readonly.username,document.getElementById(`${h}-readonly-token`).value=p.readonly.token,document.getElementById(`${h}-admin-username`).value=p.admin.username,document.getElementById(`${h}-admin-token`).value=p.admin.token,document.getElementById(`${h}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const a=t.target.closest(".token-toggle");if(a){const h=a.dataset.target,p=document.getElementById(h);p.type==="password"?(p.type="text",a.textContent="\u{1F648}"):(p.type="password",a.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=l();t.forEach(p=>{d(p,"readonly",document.getElementById(`${p}-readonly-username`).value.trim()),m(p,"readonly",document.getElementById(`${p}-readonly-token`).value.trim()),d(p,"admin",document.getElementById(`${p}-admin-username`).value.trim()),m(p,"admin",document.getElementById(`${p}-admin-token`).value.trim())});const a={};let h=!1;if(t.forEach(p=>{const w={},T=document.getElementById(`${p}-readonly-username`).value.trim(),L=document.getElementById(`${p}-readonly-token`).value.trim(),P=document.getElementById(`${p}-admin-username`).value.trim(),E=document.getElementById(`${p}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},h=!0),P&&E&&(w.admin={username:P,password:E},h=!0),Object.keys(w).length>0&&(a[p]=w)}),h){t.forEach(p=>{a[p]&&(document.getElementById(`${p}-token-status`).textContent="Verifying...",document.getElementById(`${p}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:a})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!a[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{a[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{a[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(p){console.error("Failed to sync DNS credentials to backend:",p),t.forEach(w=>{a[w]&&(document.getElementById(`${w}-token-status`).textContent="\u2713 Saved locally (sync failed)",document.getElementById(`${w}-token-status`).className="token-status")})}}else t.forEach(p=>{document.getElementById(`${p}-token-status`).textContent=""});setTimeout(()=>{t.every(w=>{const T=document.getElementById(`${w}-token-status`)?.textContent;return!T||T.includes("\u2713")})&&closeModal("token-management-modal")},1500)}),document.getElementById("token-cancel")?.addEventListener("click",()=>{closeModal("token-management-modal")}),document.getElementById("token-clear-all")?.addEventListener("click",async()=>{if(confirm("Clear all stored DNS credentials? This cannot be undone.")){o(),l().forEach(t=>{document.getElementById(`${t}-readonly-username`).value="",document.getElementById(`${t}-readonly-token`).value="",document.getElementById(`${t}-admin-username`).value="",document.getElementById(`${t}-admin-token`).value="",document.getElementById(`${t}-token-status`).textContent="\u2713 Cleared",document.getElementById(`${t}-token-status`).className="token-status success"});try{await secureFetch("/api/v1/dns/credentials",{method:"DELETE"})}catch{}}}),window.getToken=c,window.getUsername=r,window.setToken=m,window.setUsername=d,window.getAllCredentials=e,window.getCredential=s,window.setCredential=y,window.getEncryptionKey=b,window.getDnsIds=l,window.getDnsDisplayName=n})(),(function(){function l(m,d,e=null){const o=document.getElementById(m+"-dot"),i=document.getElementById(m+"-pill"),t=document.getElementById(m+"-time"),a=document.querySelector(`[data-app="${m}"]`);o&&(o.classList.toggle("ok",d),o.classList.toggle("bad",!d)),i&&(i.textContent=d?"ON":"OFF",i.classList.toggle("on",d),i.classList.toggle("off",!d)),t&&e!==null&&(t.textContent=d?`${e}ms`:"timeout",t.className=`response-time ${n(e,d)}`),a&&a.setAttribute("data-status",d?"on":"off")}function n(m,d){return d?m<200?"excellent":m<500?"good":m<1e3?"fair":"slow":"timeout"}async function g(m){const d=performance.now();try{const e=await fetch("/probe/"+m,{cache:"no-store"}),o=performance.now(),i=Math.round(o-d);return{isUp:e.status>=200&&e.status<400||e.status===401||e.status===403,responseTime:i}}catch{const e=performance.now();return{isUp:!1,responseTime:Math.round(e-d)}}}window.APPS=[];let b=null,f=!1;async function u(){try{window.SkeletonLoader&&window.SkeletonLoader.show(6);const m=await fetch("/api/v1/services",{cache:"no-store"});if(m.ok){const d=await m.json();window.APPS=d.services||[],window.SkeletonLoader&&window.SkeletonLoader.hide()}else console.error("Failed to load services:",m.status),window.SkeletonLoader&&window.SkeletonLoader.hide()}catch(m){console.error("Failed to load services:",m),window.SkeletonLoader&&window.SkeletonLoader.hide()}}function v(m){const d=window.APPS?.find(o=>o.id===m);if(d?.url)return d.url.startsWith("http")?d.url:"https://"+d.url;if(d?.isExternal&&d.externalUrl)return d.externalUrl;const e=SITE.dnsServers?.[m];return e?"http://"+e.ip+":"+(e.port||5380):buildServiceUrl(m)}function s(m,d,e){const o=document.createElement(m);return d&&(o.className=d),e&&(o.textContent=e),o}function y(){const m=document.getElementById("cards");m.innerHTML="";for(let d=0;d{D.stopPropagation(),window.openContainerLogsModal(e.containerId,e.name)},x.appendChild(k);const S=s("button","update-btn","\u2B06\uFE0F");S.title="Update container to latest version",S.id=`update-btn-${e.id}`,S.onclick=D=>{D.stopPropagation(),window.updateContainer(e.containerId,e.name,e.id)},x.appendChild(S);const A=s("button","exec-btn",">_");A.title="Open terminal",A.onclick=D=>{D.stopPropagation(),window.openExecModal&&window.openExecModal(e.containerId,e.name)},x.appendChild(A)}if(e.logPath&&!e.containerId){const k=s("button","logs-btn","\u{1F4CB}");k.title="View application logs",k.onclick=S=>{S.stopPropagation(),window.openFileLogsModal(e.logPath,e.name)},x.appendChild(k)}if(e.isExternal||e.appTemplate||e.url){const k=s("button","creds-btn","\u{1F511}");k.title="Auto-login credentials",k.id=`creds-btn-${e.id}`,k.onclick=S=>{S.stopPropagation(),window.openServiceCredsModal(e)},x.appendChild(k)}if(e.id!=="internet"){const k=s("button","options-btn","\u2699\uFE0F");k.title="Edit service settings",k.onclick=S=>{S.stopPropagation(),window.openServiceEditModal(e)},x.appendChild(k)}if(e.id!=="internet"){const k=s("button","delete-btn","\u{1F5D1}\uFE0F");k.title="Delete this service",k.onclick=S=>{S.stopPropagation(),window.deleteService(e.id,e.name)},x.appendChild(k)}const I=s("button",null,"Open");I.onclick=()=>window.open(v(e.id),"_blank","noopener"),x.appendChild(I),o.appendChild(x),o.style.transitionDelay=`${Math.min(d*45,270)}ms`,m.appendChild(o)}requestAnimationFrame(()=>{m.querySelectorAll(".card").forEach(d=>d.classList.add("loaded"))}),window.groupRecipeCards&&requestAnimationFrame(()=>window.groupRecipeCards()),window.refreshServiceFilter&&window.refreshServiceFilter()}function c(m,d,e=null){const o=document.getElementById("dot-"+m+"-grid"),i=document.getElementById("badge-"+m),t=document.getElementById("time-"+m),a=document.querySelector(`[data-app="${m}"]`);o&&(o.classList.toggle("ok",d),o.classList.toggle("bad",!d)),i&&(i.textContent=d?"ON":"OFF",i.classList.toggle("on",d),i.classList.toggle("off",!d)),t&&e!==null&&(t.textContent=d?`${e}ms`:"timeout",t.className=`response-time ${n(e,d)}`),a&&a.setAttribute("data-status",d?"on":"off")}async function r(){if(b)return f=!0,b;function m(o,i=new Date){const t=document.getElementById("stamp");t&&(t.textContent=`${o}: ${new Date(i).toLocaleTimeString()}`)}function d(o){Object.keys(SITE.dnsServers).forEach(t=>{const a=o[t];a&&l(t,a.isUp,a.responseTime)}),o.internet&&l("internet",o.internet.isUp,o.internet.responseTime),window.APPS.forEach(t=>{const a=o[t.id];a&&c(t.id,a.isUp,a.responseTime)})}async function e(){const o=Object.keys(SITE.dnsServers),i=o.map(p=>g(p));i.push(g("internet"));const t=await Promise.all(i);o.forEach((p,w)=>l(p,t[w].isUp,t[w].responseTime));const a=t[t.length-1];l("internet",a.isUp,a.responseTime),(await Promise.all(window.APPS.map(async p=>{const w=await g(p.id);return{id:p.id,...w}}))).forEach(p=>{c(p.id,p.isUp,p.responseTime)})}return b=(async()=>{try{const o=await fetch("/api/v1/services/status",{cache:"no-store"});if(!o.ok)throw new Error(`Status refresh failed (${o.status})`);const i=await o.json();d(i.statuses||{}),m("last check",i.checkedAt||new Date)}catch(o){console.warn("Batched status refresh failed, falling back to direct probes:",o);try{await e(),m("last check")}catch(i){console.error("Dashboard refresh failed:",i),m("last failed")}}finally{b=null,f&&(f=!1,setTimeout(()=>{window.refreshAll()},0))}})(),b}document.querySelector(".top")?.addEventListener("click",m=>{const d=m.target.closest('[id$="-open"]');if(!d)return;const e=d.id.replace("-open","");SITE.dnsServers[e]&&window.open(v(e),"_blank","noopener")}),document.getElementById("ca-open")?.addEventListener("click",()=>window.open(v("ca"),"_blank","noopener")),document.getElementById("creds-btn-ca")?.addEventListener("click",m=>{m.stopPropagation();const d=window.APPS.find(e=>e.id==="ca");d&&window.openServiceCredsModal&&window.openServiceCredsModal(d)}),document.getElementById("options-btn-ca")?.addEventListener("click",m=>{m.stopPropagation();const d=window.APPS.find(e=>e.id==="ca");d&&window.openServiceEditModal&&window.openServiceEditModal(d)}),document.getElementById("delete-btn-ca")?.addEventListener("click",m=>{m.stopPropagation(),window.deleteService&&window.deleteService("ca","DashCA")}),window.loadServices=u,window.buildGrid=y,window.refreshAll=r,window.setQuick=l,window.setBadge=c,window.getResponseTimeClass=n,window.checkServiceWithTiming=g,window.serviceUrl=v,window.el=s})(),(function(){async function l(s){const c=await(await secureFetch(`/api/v1/dns/restart/${s}`,{method:"POST"})).json();if(!c.success)throw new Error(c.error||"Restart failed");return c}document.querySelector(".top")?.addEventListener("click",async s=>{const y=s.target.closest('[id$="-restart"]');if(!y)return;const c=y.id.replace("-restart","");if(SITE.dnsServers[c]&&confirm(`Restart ${c.toUpperCase()} service?`))try{await withButton(y,"...",()=>l(c)),setTimeout(window.refreshAll,DC.DELAYS.RELOAD)}catch(r){showNotification("Restart failed: "+r.message,"error")}});async function n(s,y){const c=document.getElementById(`${s}-update`),r=c?.textContent||"\u2B06\uFE0F";try{c.textContent="\u{1F50D}",c.disabled=!0,c.title="Checking for updates...";const d=await(await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(y)}`)).json();if(!d.success)throw new Error(d.error||"Failed to check for updates");if(!d.updateAvailable){c.textContent="\u2705",c.title=`Already on latest version (${d.currentVersion})`,showNotification(`${s.toUpperCase()} is already up to date! Current version: ${d.currentVersion}`,"info"),setTimeout(()=>{c.textContent=r,c.disabled=!1,c.title="Update DNS server"},3e3);return}if(!confirm(`Update available for ${s.toUpperCase()}! -Current: ${g.currentVersion} -New: ${g.updateVersion} +Current: ${d.currentVersion} +New: ${d.updateVersion} -`+(g.updateTitle?`${g.updateTitle} +`+(d.updateTitle?`${d.updateTitle} `:"")+`The DNS server will restart during the update. -Proceed?`)){c.textContent=o,c.disabled=!1,c.title="Update DNS server";return}c.textContent="\u{1F504}",c.title="Updating...";const f=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(h)}`,{method:"POST"})).json();if(!f.success)throw new Error(f.error||"Update failed");if(f.manualUpdateRequired){c.textContent="\u2B06\uFE0F",c.title=`Update available: ${f.newVersion}`;const n=f.downloadLink?` -Download: ${f.downloadLink}`:"",u=f.instructionsLink?` -Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} update requires manual installation. Current: ${f.previousVersion} \u2192 ${f.newVersion}. Please update manually on the host machine.`,"warning",8e3),c.disabled=!1;return}c.textContent="\u2705",c.title="Updated successfully!",showNotification(`${t.toUpperCase()} updated successfully! ${f.previousVersion} \u2192 ${f.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server",window.refreshAll()},1e4)}catch(l){console.error("DNS update error:",l),c.textContent="\u274C",c.title="Update failed",showNotification(`Failed to update ${t.toUpperCase()}: ${l.message}`,"error"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",t=>{const h=t.target.closest('[id$="-update"]');if(!h)return;const c=h.id.replace("-update","");SITE.dnsServers[c]&&y(c,SITE.dnsServers[c]?.ip)}),injectModal("dns-settings-modal",` +Proceed?`)){c.textContent=r,c.disabled=!1,c.title="Update DNS server";return}c.textContent="\u{1F504}",c.title="Updating...";const i=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(y)}`,{method:"POST"})).json();if(!i.success)throw new Error(i.error||"Update failed");if(i.manualUpdateRequired){c.textContent="\u2B06\uFE0F",c.title=`Update available: ${i.newVersion}`;const t=i.downloadLink?` +Download: ${i.downloadLink}`:"",a=i.instructionsLink?` +Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} update requires manual installation. Current: ${i.previousVersion} \u2192 ${i.newVersion}. Please update manually on the host machine.`,"warning",8e3),c.disabled=!1;return}c.textContent="\u2705",c.title="Updated successfully!",showNotification(`${s.toUpperCase()} updated successfully! ${i.previousVersion} \u2192 ${i.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{c.textContent=r,c.disabled=!1,c.title="Update DNS server",window.refreshAll()},1e4)}catch(m){console.error("DNS update error:",m),c.textContent="\u274C",c.title="Update failed",showNotification(`Failed to update ${s.toUpperCase()}: ${m.message}`,"error"),setTimeout(()=>{c.textContent=r,c.disabled=!1,c.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-update"]');if(!y)return;const c=y.id.replace("-update","");SITE.dnsServers[c]&&n(c,SITE.dnsServers[c]?.ip)}),injectModal("dns-settings-modal",`

DNS Settings

@@ -332,7 +366,7 @@ Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} upd
- `);let p=null;function v(t){p=t;const h=SITE.dnsServers[t]||{},c=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(h.name||t).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=h.ip||"",document.getElementById("dns-edit-port").value=h.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=h.name||"",c.classList.add("show")}async function i(){if(!p)return;const t=document.getElementById("dns-edit-ip").value.trim(),h=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,c=document.getElementById("dns-edit-name").value.trim();if(!t){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[p]={ip:t,port:String(h)},c&&(o.dnsServers[p].name=c);try{const g=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();g.success?(SITE.dnsServers[p]=o.dnsServers[p],showNotification(`${p.toUpperCase()} settings saved`,"success"),m(),window.refreshAll()):showNotification(g.error||"Failed to save settings","error")}catch(l){showNotification("Failed to save: "+l.message,"error")}}async function s(){if(p&&confirm(`Remove ${p.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const h=await(await secureFetch("/api/v1/config")).json();h.dnsServers&&delete h.dnsServers[p];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:h.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[p];const l=document.querySelector(`.top [data-app="${p}"]`);l&&l.remove(),showNotification(`${p.toUpperCase()} removed from dashboard`,"success"),m()}else showNotification(o.error||"Failed to remove","error")}catch(t){showNotification("Failed to remove: "+t.message,"error")}}function m(){closeModal("dns-settings-modal"),p=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",m),document.getElementById("dns-settings-save")?.addEventListener("click",i),document.getElementById("dns-settings-delete")?.addEventListener("click",s),document.getElementById("dns-settings-modal")?.addEventListener("click",t=>{t.target.id==="dns-settings-modal"&&m()}),document.querySelector(".top")?.addEventListener("click",t=>{const h=t.target.closest('[id$="-settings"]');if(!h)return;const c=h.id.replace("-settings","");SITE.dnsServers[c]&&(t.stopPropagation(),v(c))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",` + `);let g=null;function b(s){g=s;const y=SITE.dnsServers[s]||{},c=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(y.name||s).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=y.ip||"",document.getElementById("dns-edit-port").value=y.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=y.name||"",c.classList.add("show")}async function f(){if(!g)return;const s=document.getElementById("dns-edit-ip").value.trim(),y=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,c=document.getElementById("dns-edit-name").value.trim();if(!s){showNotification("Server IP is required","warning");return}const r={dnsServers:{}};r.dnsServers[g]={ip:s,port:String(y)},c&&(r.dnsServers[g].name=c);try{const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)})).json();d.success?(SITE.dnsServers[g]=r.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(d.error||"Failed to save settings","error")}catch(m){showNotification("Failed to save: "+m.message,"error")}}async function u(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const y=await(await secureFetch("/api/v1/config")).json();y.dnsServers&&delete y.dnsServers[g];const r=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:y.dnsServers||{}})})).json();if(r.success){delete SITE.dnsServers[g];const m=document.querySelector(`.top [data-app="${g}"]`);m&&m.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(r.error||"Failed to remove","error")}catch(s){showNotification("Failed to remove: "+s.message,"error")}}function v(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",u),document.getElementById("dns-settings-modal")?.addEventListener("click",s=>{s.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-settings"]');if(!y)return;const c=y.id.replace("-settings","");SITE.dnsServers[c]&&(s.stopPropagation(),b(c))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
@@ -356,66 +390,66 @@ Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} upd
- `);let a=null,y=null,p=!1,v=null,i=null,s=!1,m=null,t=null,h=!1,c=null,o=!1;async function l(x,w=25){try{const I=getDnsServerAddr(x),k=await fetch(`/api/v1/dns/logs?server=${I}&limit=${w}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function g(x){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[x]||"var(--fg)"}function e(x){const w=document.createElement("div");if(w.className="log-entry",w.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",x.parsed===!1)return w.style.gridTemplateColumns="1fr",w.innerHTML=`${escapeHtml(x.raw)}`,w;const I=g(x.rcode),k=x.rcode==="Refused"||x.rcode==="REFUSED";return w.innerHTML=` - ${escapeHtml(x.timestamp)} - ${escapeHtml(x.client)} - ${escapeHtml(x.domain)} - ${escapeHtml(x.type)} - ${escapeHtml(x.rcode)} - `,w}async function r(){if(h){await B();return}if(s){await T();return}if(p||!a)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const I=await l(a,x);if(I.error){w.innerHTML=` + `);let l=null,n=null,g=!1,b=null,f=null,u=!1,v=null,s=null,y=!1,c=null,r=!1;async function m(C,x=25){try{const I=getDnsServerAddr(C),k=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function d(C){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[C]||"var(--fg)"}function e(C){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",C.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`${escapeHtml(C.raw)}`,x;const I=d(C.rcode),k=C.rcode==="Refused"||C.rcode==="REFUSED";return x.innerHTML=` + ${escapeHtml(C.timestamp)} + ${escapeHtml(C.client)} + ${escapeHtml(C.domain)} + ${escapeHtml(C.type)} + ${escapeHtml(C.rcode)} + `,x}async function o(){if(y){await B();return}if(u){await T();return}if(g||!l)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await m(l,C);if(I.error){x.innerHTML=`
\u26A0\uFE0F Error
${escapeHtml(I.error)}
-
`;return}w.innerHTML=` + `;return}x.innerHTML=`
Time Client Domain Type Status -
`,I.logs&&I.logs.length>0?I.logs.forEach(k=>{const S=e(k);w.appendChild(S)}):w.innerHTML+=` + `,I.logs&&I.logs.length>0?I.logs.forEach(k=>{const S=e(k);x.appendChild(S)}):x.innerHTML+=`
No DNS queries logged yet -
`}catch(I){w.innerHTML=` + `}catch(I){x.innerHTML=`
Failed to fetch logs: ${escapeHtml(I.message)} -
`}}function f(x){a=x,p=!1,s=!1;const w=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${x.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),S&&(S.style.display="none"),w.classList.add("show"),r(),y=setInterval(r,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),y&&(clearInterval(y),y=null),b(),a=null,s=!1,v=null,i=null,h=!1,m=null,t=null,p=!1}function u(x){c&&b();const w=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),k=document.getElementById("logs-content");y&&(clearInterval(y),y=null);try{c=new EventSource(`/api/v1/logs/stream/${x}`),o=!0,w.classList.add("active"),w.textContent="\u{1F534} Live",w.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),c.onmessage=A=>{try{const O=JSON.parse(A.data);if(O.error){console.error("Stream error:",O.error),b();return}const D=document.createElement("div");D.className="log-entry",D.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(O.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",F=`${R?"STDERR":"STDOUT"}`;for(D.innerHTML=` -
${F}
-
${escapeHtml(O.text)}
- `,k.appendChild(D),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},c.onerror=A=>{console.error("EventSource error:",A),b()}}catch(S){console.error("Failed to start streaming:",S),b()}}function b(){c&&(c.close(),c=null),o=!1;const x=document.getElementById("logs-stream"),w=document.getElementById("logs-pause"),I=document.getElementById("logs-title");x&&(x.classList.remove("active"),x.textContent="\u{1F4E1} Live",x.title="Enable real-time streaming"),w&&(w.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),s&&v&&!y&&(y=setInterval(T,DC.POLL.LOGS))}async function d(x,w=100){try{const I=`/api/v1/logs/container/${x}?tail=${w}×tamps=true`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function E(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=x.stream==="stderr"?'STDERR':'STDOUT';return w.innerHTML=` + `}}function i(C){l=C,g=!1,u=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${C.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),o(),n=setInterval(o,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),n&&(clearInterval(n),n=null),h(),l=null,u=!1,b=null,f=null,y=!1,v=null,s=null,g=!1}function a(C){c&&h();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),k=document.getElementById("logs-content");n&&(clearInterval(n),n=null);try{c=new EventSource(`/api/v1/logs/stream/${C}`),r=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),c.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),h();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",_=`${R?"STDERR":"STDOUT"}`;for(O.innerHTML=` +
${_}
+
${escapeHtml(D.text)}
+ `,k.appendChild(O),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},c.onerror=A=>{console.error("EventSource error:",A),h()}}catch(S){console.error("Failed to start streaming:",S),h()}}function h(){c&&(c.close(),c=null),r=!1;const C=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");C&&(C.classList.remove("active"),C.textContent="\u{1F4E1} Live",C.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),u&&b&&!n&&(n=setInterval(T,DC.POLL.LOGS))}async function p(C,x=100){try{const I=`/api/v1/logs/container/${C}?tail=${x}×tamps=true`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(C){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=C.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=C.stream==="stderr"?'STDERR':'STDOUT';return x.innerHTML=`
${k}
-
${escapeHtml(x.text)}
- `,w}async function T(){if(p||!v||!s)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const I=await d(v,x);if(I.error){w.innerHTML=` +
${escapeHtml(C.text)}
+ `,x}async function T(){if(g||!b||!u)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(b,C);if(I.error){x.innerHTML=`
\u26A0\uFE0F Error
${escapeHtml(I.error)}
-
`;return}w.innerHTML=` + `;return}x.innerHTML=`
Stream Log Output -
`,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=E(k);w.appendChild(S)}),w.scrollTop=w.scrollHeight):w.innerHTML+=` + `,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=w(k);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
No logs available for this container -
`}catch(I){w.innerHTML=` + `}catch(I){x.innerHTML=`
Failed to fetch logs: ${escapeHtml(I.message)} -
`}}function L(x,w){v=x,i=w,s=!0,h=!1,p=!1,b();const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display=""),I.classList.add("show"),T(),y=setInterval(T,DC.POLL.LOGS)}async function P(x,w=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${w}`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function C(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.text;let k="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(k="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(k="DEBUG",S="var(--muted)");const O=`${k}`;return w.innerHTML=` -
${O}
+ `}}function L(C,x){b=C,f=x,u=!0,y=!1,g=!1,h();const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display=""),I.classList.add("show"),T(),n=setInterval(T,DC.POLL.LOGS)}async function P(C,x=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(C)}&tail=${x}`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function E(C){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=C.text;let k="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(k="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(k="DEBUG",S="var(--muted)");const D=`${k}`;return x.innerHTML=` +
${D}
${escapeHtml(I)}
- `,w}async function B(){if(p||!m||!h)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const I=await P(m,x);if(I.error){w.innerHTML=` + `,x}async function B(){if(g||!v||!y)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await P(v,C);if(I.error){x.innerHTML=`
\u26A0\uFE0F Error
${escapeHtml(I.error)}
-
`;return}w.innerHTML=` + `;return}x.innerHTML=`
Log Output (${I.count} of ${I.totalLines} lines) -
`,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=C(k);w.appendChild(S)}),w.scrollTop=w.scrollHeight):w.innerHTML+=` + `,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=E(k);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
No logs available in this file -
`}catch(I){w.innerHTML=` + `}catch(I){x.innerHTML=`
Failed to fetch logs: ${escapeHtml(I.message)} -
`}}function $(x,w){m=x,t=w,h=!0,s=!1,p=!1;const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),y=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const w=x.target.closest('[id$="-logs"]');if(!w)return;const I=w.id.replace("-logs","");SITE.dnsServers[I]&&f(I)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{p=!p;const x=document.getElementById("logs-pause");p?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),r())}),document.getElementById("log-lines")?.addEventListener("change",()=>{p||r()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!s||!v||(o?b():u(v))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=f})(),(function(){injectModal("service-edit-modal",` + `}}function $(C,x){v=C,s=x,y=!0,u=!1,g=!1;const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),n=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",C=>{const x=C.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&i(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const C=document.getElementById("logs-pause");g?(C.textContent="\u25B6\uFE0F Resume",C.classList.add("paused")):(C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),o())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||o()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!u||!b||(r?h():a(b))}),document.getElementById("logs-modal")?.addEventListener("click",C=>{C.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",C=>{C.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=i})(),(function(){injectModal("service-edit-modal",`

Edit Service

@@ -771,44 +805,44 @@ Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} upd
- `)})(),(function(){async function a(s){try{const m=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(s)}`);if(!m.ok)throw new Error(`Failed to load CAs: ${m.status}`);const t=await m.json();if(t.success){const h=document.getElementById("existing-ca-select");return h.innerHTML="",t.cas.length===0?h.innerHTML='':(h.innerHTML='',t.cas.forEach(c=>{const o=document.createElement("option");typeof c=="object"?(o.value=c.id,o.textContent=c.displayName||c.name):(o.value=c,o.textContent=c),h.appendChild(o)})),t.data.cas}else throw new Error(t.message)}catch(m){console.error("Error loading CAs:",m);const t=document.getElementById("existing-ca-select");return t.innerHTML='',[]}}function y(s){const{subdomain:m,port:t,ip:h,sslType:c,caName:o,existingCa:l,enableAuth:g,enableCors:e,customHeaders:r,upstreamPath:f,healthCheck:n,timeout:u,tailscaleOnly:b}=s;let d=`${buildDomain(m)} { -`;switch(b&&(d+=` @blocked not remote_ip 100.64.0.0/10 -`,d+=` respond @blocked "Access denied. Tailscale connection required." 403 -`),c){case"letsencrypt":break;case"caddy-managed":d+=` tls internal -`;break;case"existing-ca":l&&(d+=` tls { - ca ${l} + `)})(),(function(){async function l(u){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(u)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const s=await v.json();if(s.success){const y=document.getElementById("existing-ca-select");return y.innerHTML="",s.cas.length===0?y.innerHTML='':(y.innerHTML='',s.cas.forEach(c=>{const r=document.createElement("option");typeof c=="object"?(r.value=c.id,r.textContent=c.displayName||c.name):(r.value=c,r.textContent=c),y.appendChild(r)})),s.data.cas}else throw new Error(s.message)}catch(v){console.error("Error loading CAs:",v);const s=document.getElementById("existing-ca-select");return s.innerHTML='',[]}}function n(u){const{subdomain:v,port:s,ip:y,sslType:c,caName:r,existingCa:m,enableAuth:d,enableCors:e,customHeaders:o,upstreamPath:i,healthCheck:t,timeout:a,tailscaleOnly:h}=u;let p=`${buildDomain(v)} { +`;switch(h&&(p+=` @blocked not remote_ip 100.64.0.0/10 +`,p+=` respond @blocked "Access denied. Tailscale connection required." 403 +`),c){case"letsencrypt":break;case"caddy-managed":p+=` tls internal +`;break;case"existing-ca":m&&(p+=` tls { + ca ${m} } -`);break;case"custom-ca":o&&(d+=` tls { - ca ${o} +`);break;case"custom-ca":r&&(p+=` tls { + ca ${r} } -`);break}if(g&&(d+=` basicauth { +`);break}if(d&&(p+=` basicauth { admin $2a$14$hashed_password_here } -`),e&&(d+=` header { -`,d+=` Access-Control-Allow-Origin "*" -`,d+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" -`,d+=` Access-Control-Allow-Headers "Content-Type, Authorization" -`,d+=` } -`),r)try{const E=JSON.parse(r);d+=` header { -`,Object.entries(E).forEach(([T,L])=>{d+=` ${T} "${L}" -`}),d+=` } -`}catch{console.warn("Invalid JSON in custom headers")}return n&&(d+=` health_uri ${n} -`),d+=` reverse_proxy ${h}:${t} { -`,f&&f!=="/"&&(d+=` rewrite ${f} -`),u&&u!==30&&(d+=` transport http { -`,d+=` dial_timeout ${u}s -`,d+=` response_header_timeout ${u}s -`,d+=` } -`),d+=` } -`,d+=`} -`,d}async function p(s,m,t=DC.DEFAULTS.TTL){const h=window.getToken(getPrimaryDnsId(),"admin");if(!h)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const c=buildDomain(s),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:c,ip:m,ttl:t,token:h,server:SITE.dnsIp})});if(!o.ok){const g=await o.text();throw new Error(`DNS API Error: ${o.status} - ${g}`)}const l=await o.json();if(!l.success)throw new Error(`DNS Error: ${l.error||"Unknown error"}`);return l}async function v(s){const m={id:s.subdomain,name:s.name,logo:s.logo||`/assets/${s.subdomain}.png`};s.category&&(m.category=s.category),s.containerId&&(m.containerId=s.containerId);try{const t=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});if(!t.ok){const h=await t.json();throw new Error(h.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),m}catch(t){throw console.error("Failed to add service to config:",t),t}}async function i(s){const m=document.getElementById("service-subdomain-input").value.trim(),t=document.getElementById("service-ip-input").value.trim()||"localhost",h=document.getElementById("service-port-input").value.trim()||"80",c=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(m),upstream:`${t}:${h}`,config:s})}),o=await c.json();if(!c.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${c.status}`);return o}window.loadExistingCAs=a,window.generateCaddyConfig=y,window.createDnsRecord=p,window.addServiceToConfig=v,window.addToCaddyfile=i})(),(function(){let a=null;function y(t){a=t;const h=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${t.name}`,document.getElementById("edit-service-name").value=t.name,document.getElementById("edit-service-url-display").textContent=t.url||buildServiceUrl(t.id),document.getElementById("edit-service-logo-preview").src=t.logo||`/assets/${t.id}.png`,document.getElementById("edit-subdomain").value=t.id,document.getElementById("edit-port").value=t.port||"",document.getElementById("edit-ip").value=t.ip||"localhost",document.getElementById("edit-tailscale-only").checked=t.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=t.logo||"";const c=document.getElementById("edit-service-category");c&&(c.dataset.current=t.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),h.classList.add("show")}function p(){closeModal("service-edit-modal"),a=null}async function v(){if(!a)return;const t=document.getElementById("edit-subdomain").value.trim().toLowerCase(),h=document.getElementById("edit-service-name").value.trim(),c=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",l=document.getElementById("edit-tailscale-only").checked,g=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!t){showNotification("Subdomain is required","warning");return}const r=a.id,f=[];if(t!==r&&f.push("subdomain"),h&&h!==a.name&&f.push("name"),c&&c!==String(a.port)&&f.push("port"),o!==a.ip&&f.push("ip"),l!==(a.tailscaleOnly||!1)&&f.push("tailscale"),g&&g!==a.logo&&f.push("logo"),e!==(a.category||"")&&f.push("category"),f.length===0){p();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const b=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:r,newSubdomain:t,name:h||a.name,port:c||a.port,ip:o,tailscaleOnly:l,logo:g||void 0,category:e})})).json();if(!b.success)throw new Error(b.error||"Failed to update service");const d=window.APPS.findIndex(E=>E.id===r);d!==-1&&(window.APPS[d]={...window.APPS[d],id:t,name:h||window.APPS[d].name,port:c||window.APPS[d].port,ip:o,tailscaleOnly:l,logo:g||window.APPS[d].logo,category:e||void 0}),p(),window.buildGrid(),window.refreshAll()}catch(u){console.error("Error saving service changes:",u),showNotification(`Error saving changes: ${u.message}`,"error")}finally{n.textContent="Save Changes",n.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async t=>{const h=t.target.files[0];if(!h)return;if(!h.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const c=new FileReader;c.onload=async o=>{const l=o.target.result;if(document.getElementById("edit-service-logo-preview").src=l,document.getElementById("edit-logo-url").value=l,a)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${a.id}.png`,data:l})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},c.readAsDataURL(h)}),document.getElementById("service-edit-cancel")?.addEventListener("click",p),document.getElementById("service-edit-save")?.addEventListener("click",v),document.getElementById("service-edit-modal")?.addEventListener("click",t=>{t.target.id==="service-edit-modal"&&p()});function i(t,h,c){return new Promise(o=>{const l=document.getElementById("delete-service-modal"),g=document.getElementById("delete-modal-title"),e=document.getElementById("delete-modal-message"),r=document.getElementById("delete-modal-container-info"),f=document.getElementById("delete-modal-container-name"),n=document.getElementById("delete-modal-help"),u=document.getElementById("delete-modal-cancel"),b=document.getElementById("delete-modal-remove"),d=document.getElementById("delete-modal-delete");g.textContent=`Delete "${t}"`,h?(e.innerHTML="This service has an associated Docker container.
Choose how to proceed:",r.style.display="block",f.textContent=`Container ID: ${c?.slice(0,12)||"Unknown"}`,n.style.display="block",d.style.display="block"):(e.textContent="Remove this service from the dashboard?",r.style.display="none",n.style.display="none",d.style.display="none");const E=()=>{l.classList.remove("show"),u.removeEventListener("click",T),b.removeEventListener("click",L),d.removeEventListener("click",P),l.removeEventListener("click",C)},T=()=>{E(),o(null)},L=()=>{E(),o(!1)},P=()=>{E(),o(!0)},C=B=>{B.target===l&&(E(),o(null))};u.addEventListener("click",T),b.addEventListener("click",L),d.addEventListener("click",P),l.addEventListener("click",C),l.classList.add("show")})}async function s(t,h,c){const o=document.getElementById(`update-btn-${c}`),l=o?.textContent;if(confirm(`Update ${h} to the latest version? +`),e&&(p+=` header { +`,p+=` Access-Control-Allow-Origin "*" +`,p+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" +`,p+=` Access-Control-Allow-Headers "Content-Type, Authorization" +`,p+=` } +`),o)try{const w=JSON.parse(o);p+=` header { +`,Object.entries(w).forEach(([T,L])=>{p+=` ${T} "${L}" +`}),p+=` } +`}catch{console.warn("Invalid JSON in custom headers")}return t&&(p+=` health_uri ${t} +`),p+=` reverse_proxy ${y}:${s} { +`,i&&i!=="/"&&(p+=` rewrite ${i} +`),a&&a!==30&&(p+=` transport http { +`,p+=` dial_timeout ${a}s +`,p+=` response_header_timeout ${a}s +`,p+=` } +`),p+=` } +`,p+=`} +`,p}async function g(u,v,s=DC.DEFAULTS.TTL){const y=window.getToken(getPrimaryDnsId(),"admin");if(!y)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const c=buildDomain(u),r=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:c,ip:v,ttl:s,token:y,server:SITE.dnsIp})});if(!r.ok){const d=await r.text();throw new Error(`DNS API Error: ${r.status} - ${d}`)}const m=await r.json();if(!m.success)throw new Error(`DNS Error: ${m.error||"Unknown error"}`);return m}async function b(u){const v={id:u.subdomain,name:u.name,logo:u.logo||`/assets/${u.subdomain}.png`};u.category&&(v.category=u.category),u.containerId&&(v.containerId=u.containerId);try{const s=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!s.ok){const y=await s.json();throw new Error(y.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(s){throw console.error("Failed to add service to config:",s),s}}async function f(u){const v=document.getElementById("service-subdomain-input").value.trim(),s=document.getElementById("service-ip-input").value.trim()||"localhost",y=document.getElementById("service-port-input").value.trim()||"80",c=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${s}:${y}`,config:u})}),r=await c.json();if(!c.ok||!r.success)throw new Error(r.error||`Caddy API Error: ${c.status}`);return r}window.loadExistingCAs=l,window.generateCaddyConfig=n,window.createDnsRecord=g,window.addServiceToConfig=b,window.addToCaddyfile=f})(),(function(){let l=null;function n(s){l=s;const y=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${s.name}`,document.getElementById("edit-service-name").value=s.name,document.getElementById("edit-service-url-display").textContent=s.url||buildServiceUrl(s.id),document.getElementById("edit-service-logo-preview").src=s.logo||`/assets/${s.id}.png`,document.getElementById("edit-subdomain").value=s.id,document.getElementById("edit-port").value=s.port||"",document.getElementById("edit-ip").value=s.ip||"localhost",document.getElementById("edit-tailscale-only").checked=s.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=s.logo||"";const c=document.getElementById("edit-service-category");c&&(c.dataset.current=s.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),y.classList.add("show")}function g(){closeModal("service-edit-modal"),l=null}async function b(){if(!l)return;const s=document.getElementById("edit-subdomain").value.trim().toLowerCase(),y=document.getElementById("edit-service-name").value.trim(),c=document.getElementById("edit-port").value.trim(),r=document.getElementById("edit-ip").value.trim()||"localhost",m=document.getElementById("edit-tailscale-only").checked,d=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!s){showNotification("Subdomain is required","warning");return}const o=l.id,i=[];if(s!==o&&i.push("subdomain"),y&&y!==l.name&&i.push("name"),c&&c!==String(l.port)&&i.push("port"),r!==l.ip&&i.push("ip"),m!==(l.tailscaleOnly||!1)&&i.push("tailscale"),d&&d!==l.logo&&i.push("logo"),e!==(l.category||"")&&i.push("category"),i.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const h=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:o,newSubdomain:s,name:y||l.name,port:c||l.port,ip:r,tailscaleOnly:m,logo:d||void 0,category:e})})).json();if(!h.success)throw new Error(h.error||"Failed to update service");const p=window.APPS.findIndex(w=>w.id===o);p!==-1&&(window.APPS[p]={...window.APPS[p],id:s,name:y||window.APPS[p].name,port:c||window.APPS[p].port,ip:r,tailscaleOnly:m,logo:d||window.APPS[p].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(a){console.error("Error saving service changes:",a),showNotification(`Error saving changes: ${a.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async s=>{const y=s.target.files[0];if(!y)return;if(!y.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const c=new FileReader;c.onload=async r=>{const m=r.target.result;if(document.getElementById("edit-service-logo-preview").src=m,document.getElementById("edit-logo-url").value=m,l)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:m})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},c.readAsDataURL(y)}),document.getElementById("service-edit-cancel")?.addEventListener("click",g),document.getElementById("service-edit-save")?.addEventListener("click",b),document.getElementById("service-edit-modal")?.addEventListener("click",s=>{s.target.id==="service-edit-modal"&&g()});function f(s,y,c){return new Promise(r=>{const m=document.getElementById("delete-service-modal"),d=document.getElementById("delete-modal-title"),e=document.getElementById("delete-modal-message"),o=document.getElementById("delete-modal-container-info"),i=document.getElementById("delete-modal-container-name"),t=document.getElementById("delete-modal-help"),a=document.getElementById("delete-modal-cancel"),h=document.getElementById("delete-modal-remove"),p=document.getElementById("delete-modal-delete");d.textContent=`Delete "${s}"`,y?(e.innerHTML="This service has an associated Docker container.
Choose how to proceed:",o.style.display="block",i.textContent=`Container ID: ${c?.slice(0,12)||"Unknown"}`,t.style.display="block",p.style.display="block"):(e.textContent="Remove this service from the dashboard?",o.style.display="none",t.style.display="none",p.style.display="none");const w=()=>{m.classList.remove("show"),a.removeEventListener("click",T),h.removeEventListener("click",L),p.removeEventListener("click",P),m.removeEventListener("click",E)},T=()=>{w(),r(null)},L=()=>{w(),r(!1)},P=()=>{w(),r(!0)},E=B=>{B.target===m&&(w(),r(null))};a.addEventListener("click",T),h.addEventListener("click",L),p.addEventListener("click",P),m.addEventListener("click",E),m.classList.add("show")})}async function u(s,y,c){const r=document.getElementById(`update-btn-${c}`),m=r?.textContent;if(confirm(`Update ${y} to the latest version? This will: 1. Pull the latest image 2. Stop the container 3. Recreate with same settings -The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${t}/update`,{method:"POST"})).json();if(e.success){const r=window.APPS.find(f=>f.id===c);r&&e.newContainerId&&(r.containerId=e.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=l,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${h} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(g){console.error("Update error:",g),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=l,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${h}: ${g.message}`,"error")}}async function m(t,h){const c=window.APPS.find(d=>d.id===t),o=c?buildDomain(c.id):null,l=c?.containerId,g=await i(h||t,l,c?.containerId);if(g===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(g&&l)try{const d=new URLSearchParams({containerId:c.containerId,subdomain:c.id,ip:c.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(c.id)}?${d.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(d){console.error("App removal error:",d)}else if(g&&o){try{const d=c?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(d)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(d){e.dns=d.message}try{const E=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();e.caddy=E.success||E.error&&E.error.includes("not found")?"removed":E.error||"failed"}catch(d){e.caddy=d.message}}const r=window.APPS.findIndex(d=>d.id===t);r>-1&&(window.APPS.splice(r,1),e.dashboard=!0);try{const d=safeGetJSON("custom-apps",[]),E=d.findIndex(T=>T.id===t);E>-1&&(d.splice(E,1),safeSet("custom-apps",JSON.stringify(d)))}catch{}try{const E=await(await secureFetch(`/api/v1/services/${encodeURIComponent(t)}`,{method:"DELETE"})).json();e.service=E.success?"removed":E.error||"failed"}catch(d){e.service=d.message}window.buildGrid(),window.refreshAll();let f=!1,n=[];e.dashboard||(f=!0,n.push("\u2717 Failed to remove from dashboard"));const u=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],b=d=>!d||u.some(E=>d.toLowerCase().includes(E.toLowerCase()));e.container&&!b(e.container)&&(f=!0,n.push(`\u26A0 Container: ${e.container}`)),e.dns&&!b(e.dns)&&(f=!0,n.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!b(e.caddy)&&(f=!0,n.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!b(e.service)&&(f=!0,n.push(`\u26A0 Service File: ${e.service}`)),f&&showNotification(`Error deleting "${h||t}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=y,window.showDeleteModal=i,window.updateContainer=s,window.deleteService=m})(),(function(){function a(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function y(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function p(){const e=document.getElementById("service-subdomain-input").value||"subdomain",r=document.getElementById("service-ip-input").value||v.lan||"localhost",f=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,u=document.getElementById("ca-name-input").value||"sami-ca",b=document.getElementById("existing-ca-select").value,d=document.getElementById("enable-auth").checked,E=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${r}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:f,ip:r,sslType:n,caName:u,existingCa:b,enableAuth:d,enableCors:E,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:C},w=window.generateCaddyConfig(x),I=document.getElementById("caddy-config-preview");I&&(I.value=w)}const v={localhost:"127.0.0.1",lan:"",tailscale:""};async function i(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const u=await n.json();u.lan&&(v.lan=u.lan),u.tailscale&&(v.tailscale=u.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),r=document.getElementById("quick-ip-tailscale");e&&(v.lan?(e.dataset.ip=v.lan,e.textContent=`LAN (${v.lan})`,e.title=`LAN IP: ${v.lan}`):e.style.display="none"),r&&(v.tailscale?(r.dataset.ip=v.tailscale,r.textContent=`Tailscale (${v.tailscale})`,r.title=`Tailscale IP: ${v.tailscale}`):r.style.display="none");const f=document.getElementById("service-ip-input");f&&!f.value&&v.lan&&(f.value=v.lan)}function s(){document.querySelectorAll(".quick-ip-btn").forEach(e=>{e.addEventListener("click",()=>{const r=e.dataset.ip;r&&(document.getElementById("service-ip-input").value=r,document.querySelectorAll(".quick-ip-btn").forEach(f=>f.classList.remove("active")),e.classList.add("active"),p())})}),document.getElementById("service-ip-input")?.addEventListener("input",e=>{const r=e.target.value;document.querySelectorAll(".quick-ip-btn").forEach(f=>{f.classList.toggle("active",f.dataset.ip===r)})})}async function m(){const e=document.getElementById("add-service-modal");e.classList.add("show");const r=e.querySelector(".weather-modal-content");r&&(r.scrollTop=0),document.body.style.overflow="hidden";const f=document.getElementById("ssl-type-select");f&&(f.value=y()),await i();const n=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(n);const u=document.getElementById("manual-tailscale-status"),b=document.getElementById("manual-tailscale-only");try{const E=await(await fetch("/api/v1/tailscale/status")).json();E.success&&E.installed&&E.connected?(u.innerHTML=` +The service will be briefly unavailable.`))try{r&&(r.textContent="\u{1F504}",r.disabled=!0,r.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${s}/update`,{method:"POST"})).json();if(e.success){const o=window.APPS.find(i=>i.id===c);o&&e.newContainerId&&(o.containerId=e.newContainerId),r&&(r.textContent="\u2705",r.title="Updated successfully!",setTimeout(()=>{r.textContent=m,r.disabled=!1,r.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${y} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(d){console.error("Update error:",d),r&&(r.textContent="\u274C",r.title="Update failed",setTimeout(()=>{r.textContent=m,r.disabled=!1,r.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${y}: ${d.message}`,"error")}}async function v(s,y){const c=window.APPS.find(p=>p.id===s),r=c?buildDomain(c.id):null,m=c?.containerId,d=await f(y||s,m,c?.containerId);if(d===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(d&&m)try{const p=new URLSearchParams({containerId:c.containerId,subdomain:c.id,ip:c.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(c.id)}?${p.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(p){console.error("App removal error:",p)}else if(d&&r){try{const p=c?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(r)}&type=A&ipAddress=${encodeURIComponent(p)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(p){e.dns=p.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(p){e.caddy=p.message}}const o=window.APPS.findIndex(p=>p.id===s);o>-1&&(window.APPS.splice(o,1),e.dashboard=!0);try{const p=safeGetJSON("custom-apps",[]),w=p.findIndex(T=>T.id===s);w>-1&&(p.splice(w,1),safeSet("custom-apps",JSON.stringify(p)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(s)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(p){e.service=p.message}window.buildGrid(),window.refreshAll();let i=!1,t=[];e.dashboard||(i=!0,t.push("\u2717 Failed to remove from dashboard"));const a=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],h=p=>!p||a.some(w=>p.toLowerCase().includes(w.toLowerCase()));e.container&&!h(e.container)&&(i=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!h(e.dns)&&(i=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!h(e.caddy)&&(i=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!h(e.service)&&(i=!0,t.push(`\u26A0 Service File: ${e.service}`)),i&&showNotification(`Error deleting "${y||s}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=n,window.showDeleteModal=f,window.updateContainer=u,window.deleteService=v})(),(function(){function l(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function n(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",o=document.getElementById("service-ip-input").value||b.lan||"localhost",i=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,a=document.getElementById("ca-name-input").value||"sami-ca",h=document.getElementById("existing-ca-select").value,p=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${o}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const C={subdomain:e,port:i,ip:o,sslType:t,caName:a,existingCa:h,enableAuth:p,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:E},x=window.generateCaddyConfig(C),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const b={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const a=await t.json();a.lan&&(b.lan=a.lan),a.tailscale&&(b.tailscale=a.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),o=document.getElementById("quick-ip-tailscale");e&&(b.lan?(e.dataset.ip=b.lan,e.textContent=`LAN (${b.lan})`,e.title=`LAN IP: ${b.lan}`):e.style.display="none"),o&&(b.tailscale?(o.dataset.ip=b.tailscale,o.textContent=`Tailscale (${b.tailscale})`,o.title=`Tailscale IP: ${b.tailscale}`):o.style.display="none");const i=document.getElementById("service-ip-input");i&&!i.value&&b.lan&&(i.value=b.lan)}function u(){document.querySelectorAll(".quick-ip-btn").forEach(e=>{e.addEventListener("click",()=>{const o=e.dataset.ip;o&&(document.getElementById("service-ip-input").value=o,document.querySelectorAll(".quick-ip-btn").forEach(i=>i.classList.remove("active")),e.classList.add("active"),g())})}),document.getElementById("service-ip-input")?.addEventListener("input",e=>{const o=e.target.value;document.querySelectorAll(".quick-ip-btn").forEach(i=>{i.classList.toggle("active",i.dataset.ip===o)})})}async function v(){const e=document.getElementById("add-service-modal");e.classList.add("show");const o=e.querySelector(".weather-modal-content");o&&(o.scrollTop=0),document.body.style.overflow="hidden";const i=document.getElementById("ssl-type-select");i&&(i.value=n()),await f();const t=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(t);const a=document.getElementById("manual-tailscale-status"),h=document.getElementById("manual-tailscale-only");try{const w=await(await fetch("/api/v1/tailscale/status")).json();w.success&&w.installed&&w.connected?(a.innerHTML=` \u2713 Connected - ${E.self?.hostname} (${E.self?.ip}) - `,b.disabled=!1):E.installed?(u.innerHTML='\u26A0 Not connected',b.disabled=!0):(u.innerHTML='Not available',b.disabled=!0)}catch{u.innerHTML='Could not check',b.disabled=!0}b.checked=!1,p()}function t(){const e=document.getElementById("service-type-local"),r=document.getElementById("service-type-external"),f=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),u=document.getElementById("tab-local"),b=document.getElementById("tab-external");function d(){e.checked?(f.style.display="grid",n.style.display="none",u&&(u.style.background="var(--accent)",u.style.color="var(--bg)"),b&&(b.style.background="transparent",b.style.color="var(--muted)")):(f.style.display="none",n.style.display="block",b&&(b.style.background="var(--accent)",b.style.color="var(--bg)"),u&&(u.style.background="transparent",u.style.color="var(--muted)"))}e?.addEventListener("change",d),r?.addEventListener("change",d)}function h(){const e=document.getElementById("service-name-input"),r=document.getElementById("service-subdomain-input"),f=document.getElementById("subdomain-preview");let n=!1;e?.addEventListener("input",()=>{const L=a(e.value);!n&&r&&(r.value=L),f&&(f.textContent=L?`\u2192 ${buildDomain(L)}`:""),p()}),r?.addEventListener("input",()=>{n=r.value!==a(e?.value||"");const L=r.value.trim()||a(e?.value||"");f&&(f.textContent=L?`\u2192 ${buildDomain(L)}`:""),p()});const u=document.getElementById("external-service-name"),b=document.getElementById("external-service-subdomain"),d=document.getElementById("external-subdomain-preview"),E=document.getElementById("external-domain-preview");let T=!1;u?.addEventListener("input",()=>{const L=a(u.value);!T&&b&&(b.value=L);const P=b?.value||L;d&&(d.textContent=P?`\u2192 ${buildDomain(P)}`:""),E&&(E.textContent=P?buildDomain(P):"")}),b?.addEventListener("input",()=>{T=b.value!==a(u?.value||"");const L=b.value.trim()||a(u?.value||"");d&&(d.textContent=L?`\u2192 ${buildDomain(L)}`:""),E&&(E.textContent=L?buildDomain(L):"")})}async function c(){const e=document.getElementById("external-service-name").value.trim(),r=document.getElementById("external-service-url").value.trim(),f=(document.getElementById("external-service-subdomain").value.trim()||a(e)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),u=document.getElementById("external-service-icon").value.trim(),b=document.getElementById("external-create-dns").checked,d=document.getElementById("external-create-caddy").checked,E=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!r){showNotification("Please fill in Name and External URL","warning");return}if(!f){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!r.startsWith("http://")&&!r.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(f);try{const B={dns:null,caddy:null,dashboard:!1};if(b)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:E,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(d)try{const k={subdomain:f,externalUrl:r,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:f,name:e,url:`https://${C}`,externalUrl:r,logo:n||u||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const x=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(k=>!x.includes(k.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),o();const I=[`External service "${e}" added!`];b&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),d&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${C}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`Failed to create external service: ${B.message}`,"error")}}function o(){closeModal("add-service-modal"),document.body.style.overflow="",document.getElementById("service-name-input").value="",document.getElementById("service-subdomain-input").value="",document.getElementById("service-port-input").value="",document.getElementById("service-ip-input").value=v.lan||"",document.getElementById("service-logo-input").value="",document.getElementById("dns-ttl-input").value=DC.DEFAULTS.TTL,document.getElementById("ssl-type-select").value=y(),document.getElementById("ca-name-input").value="",document.getElementById("enable-auth").checked=!1,document.getElementById("enable-cors").checked=!1,document.getElementById("custom-headers-input").value="",document.getElementById("upstream-path-input").value="/",document.getElementById("health-check-input").value="",document.getElementById("timeout-input").value="30";const e=document.getElementById("subdomain-preview");e&&(e.textContent="");const r=document.getElementById("external-subdomain-preview");r&&(r.textContent="");const f=document.getElementById("external-service-name");f&&(f.value="");const n=document.getElementById("external-service-subdomain");n&&(n.value="");const u=document.getElementById("external-service-url");u&&(u.value="");const b=document.getElementById("external-service-logo");b&&(b.value="");const d=document.getElementById("external-service-icon");d&&(d.value="");const E=document.getElementById("local-advanced-options");E&&E.removeAttribute("open");const T=document.getElementById("external-advanced-options");T&&T.removeAttribute("open");const L=document.getElementById("service-type-local");L&&(L.checked=!0);const P=document.getElementById("local-service-config"),C=document.getElementById("external-service-config");P&&(P.style.display="grid"),C&&(C.style.display="none");const B=document.getElementById("tab-local"),$=document.getElementById("tab-external");B&&(B.style.background="var(--accent)",B.style.color="var(--bg)"),$&&($.style.background="transparent",$.style.color="var(--muted)")}async function l(){const e=document.getElementById("service-name-input").value.trim(),r=(document.getElementById("service-subdomain-input").value.trim()||a(e)).toLowerCase(),f=document.getElementById("service-port-input").value.trim(),n=document.getElementById("service-ip-input").value.trim(),u=document.getElementById("service-logo-input").value.trim(),b=document.getElementById("create-dns-record").checked,d=parseInt(document.getElementById("dns-ttl-input").value)||DC.DEFAULTS.TTL,E=document.getElementById("manual-tailscale-only")?.checked||!1,T=document.getElementById("ssl-type-select")?.value||"caddy-managed",L=document.getElementById("ca-name-input")?.value||"",P=document.getElementById("existing-ca-select")?.value||"",C=document.getElementById("enable-auth")?.checked||!1,B=document.getElementById("enable-cors")?.checked||!1,$=document.getElementById("custom-headers-input")?.value||"",x=document.getElementById("upstream-path-input")?.value||"/",w=document.getElementById("health-check-input")?.value||"",I=document.getElementById("timeout-input")?.value||30,S=(document.getElementById("service-category-input")||document.getElementById("external-service-category"))?.value||"",A=window.getToken(getPrimaryDnsId(),"admin");if(!e||!f||!n){showNotification("Please fill in Name, Port, and IP Address","warning");return}if(!r){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(b&&!A){showNotification("DNS Admin token required. Configure it in the Tokens menu first.","warning");return}const O={dns:null,caddy:null,dashboard:!1};try{if(b)try{await window.createDnsRecord(r,n,d),O.dns="created"}catch(N){throw console.error("DNS creation failed:",N),O.dns=N.message,new Error(`DNS creation failed: ${N.message}`)}else O.dns="skipped";const D=window.generateCaddyConfig({subdomain:r,port:f,ip:n,sslType:T,caName:L,existingCa:P,enableAuth:C,enableCors:B,customHeaders:$,upstreamPath:x,healthCheck:w,timeout:I,tailscaleOnly:E});try{const U=await(await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(r),upstream:`${n}:${f}`,config:D})})).json();if(U.success)O.caddy="added & reloaded";else throw console.error("Caddy configuration failed:",U.error),O.caddy=U.error||"failed",new Error(`Caddy configuration failed: ${U.error}`)}catch(N){throw console.error("Caddy API error:",N),O.caddy=N.message,new Error(`Caddy API error: ${N.message}`)}const M={name:e,subdomain:r,port:f,ip:n,logo:u||`/assets/${r}.png`,tailscaleOnly:E||!1};S&&(M.category=S),await window.addServiceToConfig(M),O.dashboard=!0;const R=[`DNS: ${O.dns==="created"?"\u2713":O.dns==="skipped"?"\u25CB":"\u2717"}`,`Caddy: ${O.caddy==="added & reloaded"?"\u2713":"\u2717"}`,`Dashboard: ${O.dashboard?"\u2713":"\u2717"}`];showNotification(`Service "${e}" created! ${R.join(" | ")} \u2014 ${buildServiceUrl(r)}${E?" (Tailscale)":""}`,"success",6e3),o(),window.buildGrid(),window.refreshAll()}catch(D){console.error("Error creating service:",D),showNotification(`Error creating "${e}": ${D.message}`,"error",6e3)}}document.getElementById("add-service")?.addEventListener("click",m),document.getElementById("add-service-cancel")?.addEventListener("click",o),document.getElementById("add-service-create")?.addEventListener("click",()=>{document.querySelector('input[name="service-type"]:checked')?.value==="external"?c():l()}),t(),h(),s(),document.getElementById("ssl-type-select")?.addEventListener("change",e=>{const r=document.getElementById("existing-ca-config"),f=document.getElementById("custom-ca-config");r.style.display="none",f.style.display="none",e.target.value==="existing-ca"?r.style.display="block":e.target.value==="custom-ca"&&(f.style.display="block"),p()}),document.getElementById("refresh-cas")?.addEventListener("click",async()=>{const e=document.getElementById("refresh-cas"),r=e.textContent;e.textContent="\u231B Loading...",e.disabled=!0;try{const f=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(f),e.textContent="\u2705 Refreshed"}catch(f){e.textContent="\u274C Failed",console.error("Failed to refresh CAs:",f)}setTimeout(()=>{e.textContent=r,e.disabled=!1},2e3)}),document.getElementById("create-dns-record")?.addEventListener("change",e=>{const r=document.getElementById("dns-config");r.style.display=e.target.checked?"block":"none"}),["service-subdomain-input","service-ip-input","service-port-input","ca-name-input","existing-ca-select","enable-auth","enable-cors","custom-headers-input","upstream-path-input","health-check-input","timeout-input"].forEach(e=>{const r=document.getElementById(e);r&&(r.addEventListener("input",p),r.addEventListener("change",p))});function g(){const e=safeGet("custom-services");if(e)try{JSON.parse(e).forEach(f=>{window.APPS.find(n=>n.id===f.id)||window.APPS.push(f)})}catch(r){console.warn("Failed to load custom services:",r)}}g(),window.openAddServiceModal=m,window.closeAddServiceModal=o})(),(function(){let a=null,y=1e3;const p=3e4;function v(){if(a)try{a.close()}catch{}a=new EventSource("/api/v1/events/stream"),a.addEventListener("connected",()=>{y=1e3,debug("[SSE] Connected to event stream")}),a.addEventListener("status-change",i=>{try{const s=JSON.parse(i.data);if(s.serviceId&&typeof window.setBadge=="function"){const m=s.status==="up"||s.status==="healthy";window.setBadge(s.serviceId,m,s.responseTime||null)}}catch{}}),a.addEventListener("resource-alert",i=>{try{const s=JSON.parse(i.data),m=`${s.containerName||s.containerId}: ${s.metric} at ${s.value}% (threshold: ${s.threshold}%)`;typeof showNotification=="function"&&showNotification(m,"warning")}catch{}}),a.addEventListener("auto-restart",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&showNotification(`Container "${s.containerName}" was auto-restarted`,"info")}catch{}}),a.addEventListener("update-available",i=>{try{const s=JSON.parse(i.data),m=document.getElementById("updates-btn");if(m&&!m.querySelector(".sse-dot")){const t=document.createElement("span");t.className="sse-dot",t.style.cssText="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;",m.appendChild(t)}typeof showNotification=="function"&&showNotification(`Update available for ${s.containerName||s.containerId}`,"info")}catch{}}),a.addEventListener("update-complete",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&showNotification(`Update completed: ${s.containerName||s.containerId}`,"success"),typeof window.refreshAll=="function"&&window.refreshAll()}catch{}}),a.addEventListener("update-failed",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&showNotification(`Update failed: ${s.containerName||s.containerId} \u2014 ${s.error||"unknown error"}`,"error")}catch{}}),a.addEventListener("incident",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&(s.type==="created"?showNotification(`Incident: ${s.message||s.serviceId}`,"error"):s.type==="resolved"&&showNotification(`Resolved: ${s.serviceId||"incident"}`,"success"))}catch{}}),a.onerror=()=>{a.close(),console.warn(`[SSE] Disconnected, reconnecting in ${y/1e3}s...`),setTimeout(v,y),y=Math.min(y*2,p)}}v(),window._sseReconnect=v})(),(function(){const a=document.getElementById("service-filter-search"),y=document.getElementById("service-filter-status"),p=document.getElementById("service-filter-category"),v=document.getElementById("service-filter-count");function i(){const h=new Set,c=new Set;document.querySelectorAll("#cards .card[data-category]").forEach(g=>{const e=g.dataset.category.trim();e&&c.add(e)});const o=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES||{};return Object.keys(o).concat([...c].filter(g=>!o[g])).forEach(g=>h.add(g)),{list:[...h],apiCats:o}}function s(){if(!p)return;const{list:h,apiCats:c}=i(),o=p.value;p.innerHTML='',h.sort().forEach(l=>{const g=c[l],e=document.createElement("option");e.value=l,e.textContent=g?`${g.icon||""} ${l}`.trim():l,p.appendChild(e)}),o&&[...p.options].some(l=>l.value===o)?p.value=o:p.value="all"}function m(){s();const h=a.value.toLowerCase().trim(),c=y.value,o=p?p.value:"all",l=document.querySelectorAll("#cards .card");let g=0;if(l.forEach(e=>{const r=e.querySelector(".name")?.textContent?.toLowerCase()||"",f=e.dataset.app?.toLowerCase()||"",n=e.dataset.status||"off",u=e.dataset.category||"";(!h||r.includes(h)||f.includes(h))&&(c==="all"||n===c)&&(o==="all"||u===o)?(e.style.display="",g++):e.style.display="none"}),v){const e=l.length;v.textContent=`${g} of ${e} services`}}function t(h,c){let o;return function(...l){clearTimeout(o),o=setTimeout(()=>h.apply(this,l),c)}}a?.addEventListener("input",t(m,200)),y?.addEventListener("change",m),p?.addEventListener("change",m),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>setTimeout(m,500)):setTimeout(m,500),window.refreshServiceFilter=m,window.refreshCategoryDropdown=s})(),(function(){const a=document.getElementById("batch-operations-btn"),y=document.getElementById("batch-action-bar"),p=document.getElementById("batch-selected-count"),v=document.getElementById("batch-start-btn"),i=document.getElementById("batch-stop-btn"),s=document.getElementById("batch-restart-btn"),m=document.getElementById("batch-cancel-btn");let t=!1,h=new Set;function c(){t=!0,h.clear(),y.style.display="",a.textContent="\u2713 Exit Batch Mode",l(),document.querySelectorAll("#cards .card[data-app]").forEach(r=>{const f=r.dataset.containerId;if(!f)return;const n=r.querySelector(".batch-checkbox");n&&n.remove();const u=document.createElement("input");u.type="checkbox",u.className="batch-checkbox",u.dataset.containerId=f,u.dataset.serviceName=r.querySelector(".name")?.textContent||f,u.style.cssText="position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;",u.addEventListener("change",b=>{b.stopPropagation(),u.checked?h.add(f):h.delete(f),l()}),r.style.position="relative",r.insertBefore(u,r.firstChild)})}function o(){t=!1,h.clear(),y.style.display="none",a.textContent="\u2630 Batch Operations",document.querySelectorAll(".batch-checkbox").forEach(e=>e.remove())}function l(){const e=h.size;p.textContent=`${e} selected`,v.disabled=e===0,i.disabled=e===0,s.disabled=e===0}async function g(e){if(h.size===0)return;const r=Array.from(h),f={start:"Starting",stop:"Stopping",restart:"Restarting"}[e];if(!confirm(`${f} ${r.length} container(s)? This cannot be undone.`))return;const n=[v,i,s];n.forEach(E=>{E.disabled=!0,E.textContent="..."});let u=0,b=0;const d=[];for(const E of r)try{const T=await fetch(`/api/v1/containers/${encodeURIComponent(E)}/${e}`,{method:"POST"});if(T.ok)u++;else{b++;const L=await T.json().catch(()=>({}));d.push(`${E}: ${L.error||T.statusText}`)}}catch(T){b++,d.push(`${E}: ${T.message}`)}n[0].textContent="\u25B6 Start All",n[1].textContent="\u2B1B Stop All",n[2].textContent="\u{1F504} Restart All",l(),b===0?typeof showNotification=="function"&&showNotification(`${f} completed: ${u} container(s)`,"success"):(typeof showNotification=="function"&&showNotification(`${f}: ${u} succeeded, ${b} failed`,"warning"),console.error("Batch operation errors:",d)),setTimeout(()=>{typeof refreshAll=="function"&&refreshAll()},1500)}a?.addEventListener("click",()=>{t?o():c()}),v?.addEventListener("click",()=>g("start")),i?.addEventListener("click",()=>g("stop")),s?.addEventListener("click",()=>g("restart")),m?.addEventListener("click",o)})(); + ${w.self?.hostname} (${w.self?.ip}) + `,h.disabled=!1):w.installed?(a.innerHTML='\u26A0 Not connected',h.disabled=!0):(a.innerHTML='Not available',h.disabled=!0)}catch{a.innerHTML='Could not check',h.disabled=!0}h.checked=!1,g()}function s(){const e=document.getElementById("service-type-local"),o=document.getElementById("service-type-external"),i=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),a=document.getElementById("tab-local"),h=document.getElementById("tab-external");function p(){e.checked?(i.style.display="grid",t.style.display="none",a&&(a.style.background="var(--accent)",a.style.color="var(--bg)"),h&&(h.style.background="transparent",h.style.color="var(--muted)")):(i.style.display="none",t.style.display="block",h&&(h.style.background="var(--accent)",h.style.color="var(--bg)"),a&&(a.style.background="transparent",a.style.color="var(--muted)"))}e?.addEventListener("change",p),o?.addEventListener("change",p)}function y(){const e=document.getElementById("service-name-input"),o=document.getElementById("service-subdomain-input"),i=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=l(e.value);!t&&o&&(o.value=L),i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),o?.addEventListener("input",()=>{t=o.value!==l(e?.value||"");const L=o.value.trim()||l(e?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const a=document.getElementById("external-service-name"),h=document.getElementById("external-service-subdomain"),p=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;a?.addEventListener("input",()=>{const L=l(a.value);!T&&h&&(h.value=L);const P=h?.value||L;p&&(p.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),h?.addEventListener("input",()=>{T=h.value!==l(a?.value||"");const L=h.value.trim()||l(a?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function c(){const e=document.getElementById("external-service-name").value.trim(),o=document.getElementById("external-service-url").value.trim(),i=(document.getElementById("external-service-subdomain").value.trim()||l(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),a=document.getElementById("external-service-icon").value.trim(),h=document.getElementById("external-create-dns").checked,p=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!o){showNotification("Please fill in Name and External URL","warning");return}if(!i){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!o.startsWith("http://")&&!o.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const E=buildDomain(i);try{const B={dns:null,caddy:null,dashboard:!1};if(h)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:E,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(p)try{const k={subdomain:i,externalUrl:o,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:i,name:e,url:`https://${E}`,externalUrl:o,logo:t||a||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const C=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(k=>!C.includes(k.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),r();const I=[`External service "${e}" added!`];h&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),p&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${E}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`Failed to create external service: ${B.message}`,"error")}}function r(){closeModal("add-service-modal"),document.body.style.overflow="",document.getElementById("service-name-input").value="",document.getElementById("service-subdomain-input").value="",document.getElementById("service-port-input").value="",document.getElementById("service-ip-input").value=b.lan||"",document.getElementById("service-logo-input").value="",document.getElementById("dns-ttl-input").value=DC.DEFAULTS.TTL,document.getElementById("ssl-type-select").value=n(),document.getElementById("ca-name-input").value="",document.getElementById("enable-auth").checked=!1,document.getElementById("enable-cors").checked=!1,document.getElementById("custom-headers-input").value="",document.getElementById("upstream-path-input").value="/",document.getElementById("health-check-input").value="",document.getElementById("timeout-input").value="30";const e=document.getElementById("subdomain-preview");e&&(e.textContent="");const o=document.getElementById("external-subdomain-preview");o&&(o.textContent="");const i=document.getElementById("external-service-name");i&&(i.value="");const t=document.getElementById("external-service-subdomain");t&&(t.value="");const a=document.getElementById("external-service-url");a&&(a.value="");const h=document.getElementById("external-service-logo");h&&(h.value="");const p=document.getElementById("external-service-icon");p&&(p.value="");const w=document.getElementById("local-advanced-options");w&&w.removeAttribute("open");const T=document.getElementById("external-advanced-options");T&&T.removeAttribute("open");const L=document.getElementById("service-type-local");L&&(L.checked=!0);const P=document.getElementById("local-service-config"),E=document.getElementById("external-service-config");P&&(P.style.display="grid"),E&&(E.style.display="none");const B=document.getElementById("tab-local"),$=document.getElementById("tab-external");B&&(B.style.background="var(--accent)",B.style.color="var(--bg)"),$&&($.style.background="transparent",$.style.color="var(--muted)")}async function m(){const e=document.getElementById("service-name-input").value.trim(),o=(document.getElementById("service-subdomain-input").value.trim()||l(e)).toLowerCase(),i=document.getElementById("service-port-input").value.trim(),t=document.getElementById("service-ip-input").value.trim(),a=document.getElementById("service-logo-input").value.trim(),h=document.getElementById("create-dns-record").checked,p=parseInt(document.getElementById("dns-ttl-input").value)||DC.DEFAULTS.TTL,w=document.getElementById("manual-tailscale-only")?.checked||!1,T=document.getElementById("ssl-type-select")?.value||"caddy-managed",L=document.getElementById("ca-name-input")?.value||"",P=document.getElementById("existing-ca-select")?.value||"",E=document.getElementById("enable-auth")?.checked||!1,B=document.getElementById("enable-cors")?.checked||!1,$=document.getElementById("custom-headers-input")?.value||"",C=document.getElementById("upstream-path-input")?.value||"/",x=document.getElementById("health-check-input")?.value||"",I=document.getElementById("timeout-input")?.value||30,S=(document.getElementById("service-category-input")||document.getElementById("external-service-category"))?.value||"",A=window.getToken(getPrimaryDnsId(),"admin");if(!e||!i||!t){showNotification("Please fill in Name, Port, and IP Address","warning");return}if(!o){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(h&&!A){showNotification("DNS Admin token required. Configure it in the Tokens menu first.","warning");return}const D={dns:null,caddy:null,dashboard:!1};try{if(h)try{await window.createDnsRecord(o,t,p),D.dns="created"}catch(N){throw console.error("DNS creation failed:",N),D.dns=N.message,new Error(`DNS creation failed: ${N.message}`)}else D.dns="skipped";const O=window.generateCaddyConfig({subdomain:o,port:i,ip:t,sslType:T,caName:L,existingCa:P,enableAuth:E,enableCors:B,customHeaders:$,upstreamPath:C,healthCheck:x,timeout:I,tailscaleOnly:w});try{const U=await(await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(o),upstream:`${t}:${i}`,config:O})})).json();if(U.success)D.caddy="added & reloaded";else throw console.error("Caddy configuration failed:",U.error),D.caddy=U.error||"failed",new Error(`Caddy configuration failed: ${U.error}`)}catch(N){throw console.error("Caddy API error:",N),D.caddy=N.message,new Error(`Caddy API error: ${N.message}`)}const M={name:e,subdomain:o,port:i,ip:t,logo:a||`/assets/${o}.png`,tailscaleOnly:w||!1};S&&(M.category=S),await window.addServiceToConfig(M),D.dashboard=!0;const R=[`DNS: ${D.dns==="created"?"\u2713":D.dns==="skipped"?"\u25CB":"\u2717"}`,`Caddy: ${D.caddy==="added & reloaded"?"\u2713":"\u2717"}`,`Dashboard: ${D.dashboard?"\u2713":"\u2717"}`];showNotification(`Service "${e}" created! ${R.join(" | ")} \u2014 ${buildServiceUrl(o)}${w?" (Tailscale)":""}`,"success",6e3),r(),window.buildGrid(),window.refreshAll()}catch(O){console.error("Error creating service:",O),showNotification(`Error creating "${e}": ${O.message}`,"error",6e3)}}document.getElementById("add-service")?.addEventListener("click",v),document.getElementById("add-service-cancel")?.addEventListener("click",r),document.getElementById("add-service-create")?.addEventListener("click",()=>{document.querySelector('input[name="service-type"]:checked')?.value==="external"?c():m()}),s(),y(),u(),document.getElementById("ssl-type-select")?.addEventListener("change",e=>{const o=document.getElementById("existing-ca-config"),i=document.getElementById("custom-ca-config");o.style.display="none",i.style.display="none",e.target.value==="existing-ca"?o.style.display="block":e.target.value==="custom-ca"&&(i.style.display="block"),g()}),document.getElementById("refresh-cas")?.addEventListener("click",async()=>{const e=document.getElementById("refresh-cas"),o=e.textContent;e.textContent="\u231B Loading...",e.disabled=!0;try{const i=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(i),e.textContent="\u2705 Refreshed"}catch(i){e.textContent="\u274C Failed",console.error("Failed to refresh CAs:",i)}setTimeout(()=>{e.textContent=o,e.disabled=!1},2e3)}),document.getElementById("create-dns-record")?.addEventListener("change",e=>{const o=document.getElementById("dns-config");o.style.display=e.target.checked?"block":"none"}),["service-subdomain-input","service-ip-input","service-port-input","ca-name-input","existing-ca-select","enable-auth","enable-cors","custom-headers-input","upstream-path-input","health-check-input","timeout-input"].forEach(e=>{const o=document.getElementById(e);o&&(o.addEventListener("input",g),o.addEventListener("change",g))});function d(){const e=safeGet("custom-services");if(e)try{JSON.parse(e).forEach(i=>{window.APPS.find(t=>t.id===i.id)||window.APPS.push(i)})}catch(o){console.warn("Failed to load custom services:",o)}}d(),window.openAddServiceModal=v,window.closeAddServiceModal=r})(),(function(){let l=null,n=1e3;const g=3e4;function b(){if(l)try{l.close()}catch{}l=new EventSource("/api/v1/events/stream"),l.addEventListener("connected",()=>{n=1e3,debug("[SSE] Connected to event stream")}),l.addEventListener("status-change",f=>{try{const u=JSON.parse(f.data);if(u.serviceId&&typeof window.setBadge=="function"){const v=u.status==="up"||u.status==="healthy";window.setBadge(u.serviceId,v,u.responseTime||null)}}catch{}}),l.addEventListener("resource-alert",f=>{try{const u=JSON.parse(f.data),v=`${u.containerName||u.containerId}: ${u.metric} at ${u.value}% (threshold: ${u.threshold}%)`;typeof showNotification=="function"&&showNotification(v,"warning")}catch{}}),l.addEventListener("auto-restart",f=>{try{const u=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Container "${u.containerName}" was auto-restarted`,"info")}catch{}}),l.addEventListener("update-available",f=>{try{const u=JSON.parse(f.data),v=document.getElementById("updates-btn");if(v&&!v.querySelector(".sse-dot")){const s=document.createElement("span");s.className="sse-dot",s.style.cssText="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;",v.appendChild(s)}typeof showNotification=="function"&&showNotification(`Update available for ${u.containerName||u.containerId}`,"info")}catch{}}),l.addEventListener("update-complete",f=>{try{const u=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Update completed: ${u.containerName||u.containerId}`,"success"),typeof window.refreshAll=="function"&&window.refreshAll()}catch{}}),l.addEventListener("update-failed",f=>{try{const u=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Update failed: ${u.containerName||u.containerId} \u2014 ${u.error||"unknown error"}`,"error")}catch{}}),l.addEventListener("incident",f=>{try{const u=JSON.parse(f.data);typeof showNotification=="function"&&(u.type==="created"?showNotification(`Incident: ${u.message||u.serviceId}`,"error"):u.type==="resolved"&&showNotification(`Resolved: ${u.serviceId||"incident"}`,"success"))}catch{}}),l.onerror=()=>{l.close(),console.warn(`[SSE] Disconnected, reconnecting in ${n/1e3}s...`),setTimeout(b,n),n=Math.min(n*2,g)}}b(),window._sseReconnect=b})(),(function(){const l=document.getElementById("service-filter-search"),n=document.getElementById("service-filter-status"),g=document.getElementById("service-filter-category"),b=document.getElementById("service-filter-count");function f(){const y=new Set,c=new Set;document.querySelectorAll("#cards .card[data-category]").forEach(d=>{const e=d.dataset.category.trim();e&&c.add(e)});const r=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES||{};return Object.keys(r).concat([...c].filter(d=>!r[d])).forEach(d=>y.add(d)),{list:[...y],apiCats:r}}function u(){if(!g)return;const{list:y,apiCats:c}=f(),r=g.value;g.innerHTML='',y.sort().forEach(m=>{const d=c[m],e=document.createElement("option");e.value=m,e.textContent=d?`${d.icon||""} ${m}`.trim():m,g.appendChild(e)}),r&&[...g.options].some(m=>m.value===r)?g.value=r:g.value="all"}function v(){u();const y=l.value.toLowerCase().trim(),c=n.value,r=g?g.value:"all",m=document.querySelectorAll("#cards .card");let d=0;if(m.forEach(e=>{const o=e.querySelector(".name")?.textContent?.toLowerCase()||"",i=e.dataset.app?.toLowerCase()||"",t=e.dataset.status||"off",a=e.dataset.category||"";(!y||o.includes(y)||i.includes(y))&&(c==="all"||t===c)&&(r==="all"||a===r)?(e.style.display="",d++):e.style.display="none"}),b){const e=m.length;b.textContent=`${d} of ${e} services`}}function s(y,c){let r;return function(...m){clearTimeout(r),r=setTimeout(()=>y.apply(this,m),c)}}l?.addEventListener("input",s(v,200)),n?.addEventListener("change",v),g?.addEventListener("change",v),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>setTimeout(v,500)):setTimeout(v,500),window.refreshServiceFilter=v,window.refreshCategoryDropdown=u})(),(function(){const l=document.getElementById("batch-operations-btn"),n=document.getElementById("batch-action-bar"),g=document.getElementById("batch-selected-count"),b=document.getElementById("batch-start-btn"),f=document.getElementById("batch-stop-btn"),u=document.getElementById("batch-restart-btn"),v=document.getElementById("batch-cancel-btn");let s=!1,y=new Set;function c(){s=!0,y.clear(),n.style.display="",l.textContent="\u2713 Exit Batch Mode",m(),document.querySelectorAll("#cards .card[data-app]").forEach(o=>{const i=o.dataset.containerId;if(!i)return;const t=o.querySelector(".batch-checkbox");t&&t.remove();const a=document.createElement("input");a.type="checkbox",a.className="batch-checkbox",a.dataset.containerId=i,a.dataset.serviceName=o.querySelector(".name")?.textContent||i,a.style.cssText="position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;",a.addEventListener("change",h=>{h.stopPropagation(),a.checked?y.add(i):y.delete(i),m()}),o.style.position="relative",o.insertBefore(a,o.firstChild)})}function r(){s=!1,y.clear(),n.style.display="none",l.textContent="\u2630 Batch Operations",document.querySelectorAll(".batch-checkbox").forEach(e=>e.remove())}function m(){const e=y.size;g.textContent=`${e} selected`,b.disabled=e===0,f.disabled=e===0,u.disabled=e===0}async function d(e){if(y.size===0)return;const o=Array.from(y),i={start:"Starting",stop:"Stopping",restart:"Restarting"}[e];if(!confirm(`${i} ${o.length} container(s)? This cannot be undone.`))return;const t=[b,f,u];t.forEach(w=>{w.disabled=!0,w.textContent="..."});let a=0,h=0;const p=[];for(const w of o)try{const T=await fetch(`/api/v1/containers/${encodeURIComponent(w)}/${e}`,{method:"POST"});if(T.ok)a++;else{h++;const L=await T.json().catch(()=>({}));p.push(`${w}: ${L.error||T.statusText}`)}}catch(T){h++,p.push(`${w}: ${T.message}`)}t[0].textContent="\u25B6 Start All",t[1].textContent="\u2B1B Stop All",t[2].textContent="\u{1F504} Restart All",m(),h===0?typeof showNotification=="function"&&showNotification(`${i} completed: ${a} container(s)`,"success"):(typeof showNotification=="function"&&showNotification(`${i}: ${a} succeeded, ${h} failed`,"warning"),console.error("Batch operation errors:",p)),setTimeout(()=>{typeof refreshAll=="function"&&refreshAll()},1500)}l?.addEventListener("click",()=>{s?r():c()}),b?.addEventListener("click",()=>d("start")),f?.addEventListener("click",()=>d("stop")),u?.addEventListener("click",()=>d("restart")),v?.addEventListener("click",r)})(); diff --git a/status/dist/features.js b/status/dist/features.js index 86dc01e..3909e42 100644 --- a/status/dist/features.js +++ b/status/dist/features.js @@ -90,44 +90,44 @@ - `);const h=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),P=document.getElementById("logo-preview-light"),w=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),O=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),b=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),B=document.getElementById("dashboard-title"),S=DC.NAME;let T=null,j=null,H=null,R="left",x=S;N?.addEventListener("change",()=>{N.checked?(O.style.display="none",z.style.display="",T=null,j=null):(O.style.display="flex",z.style.display="none",H=null)});function D(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const o=new FileReader;o.onload=a=>e(a.target.result),o.readAsDataURL(n)}A?.addEventListener("change",n=>{D(n.target.files[0],e=>{T=e,E.src=e,w.textContent="New dark logo ready to save"})}),v?.addEventListener("change",n=>{D(n.target.files[0],e=>{j=e,P.src=e,w.textContent="New light logo ready to save"})}),L?.addEventListener("change",n=>{D(n.target.files[0],e=>{H=e,E.src=e,P.src=e,w.textContent="New logo ready to save (both themes)"})});function g(n){k.setAttribute("data-logo-pos",n),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===n?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===n?"white":"var(--fg)"})}function u(n){x=n||S,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function f(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(b.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(M.src=e.customLogoLight,P.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(b.src=e.customLogo,M.src=e.customLogo,E.src=e.customLogo,P.src=e.customLogo),e.isDefault||(w.textContent="Using custom logo"),e.position&&(R=e.position,g(e.position)),e.dashboardTitle&&u(e.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{R=n.dataset.pos,g(R)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,j=null,H=null,A&&(A.value=""),v&&(v.value=""),L&&(L.value=""),N&&(N.checked=!1),O.style.display="flex",z.style.display="none",E.src=b.src,P.src=M.src;const n=b.src.includes("custom-logo")||M.src.includes("custom-logo");w.textContent=n?"Using custom logo":"Using default logos",g(R),B.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=B.value.trim()||S,e={position:R,dashboardTitle:n};N?.checked&&H?(e.dataDark=H,e.dataLight=H):(T&&(e.dataDark=T),j&&(e.dataLight=j));const o=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(o.ok){const a=await o.json(),r="?t="+Date.now();a.pathDark&&(b.src=a.pathDark+r,E.src=a.pathDark+r),a.pathLight&&(M.src=a.pathLight+r,P.src=a.pathLight+r),g(R),u(n),h.classList.remove("show")}else{const a=await o.json();showNotification("Failed to save: "+a.error,"error")}}catch(n){showNotification("Error saving: "+n.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? + `);const h=document.getElementById("logo-modal"),S=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),k=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),P=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),E=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),I=DC.NAME;let L=null,j=null,M=null,O="left",x=I;N?.addEventListener("change",()=>{N.checked?(R.style.display="none",z.style.display="",L=null,j=null):(R.style.display="flex",z.style.display="none",M=null)});function D(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const o=new FileReader;o.onload=a=>e(a.target.result),o.readAsDataURL(t)}P?.addEventListener("change",t=>{D(t.target.files[0],e=>{L=e,S.src=e,k.textContent="New dark logo ready to save"})}),g?.addEventListener("change",t=>{D(t.target.files[0],e=>{j=e,A.src=e,k.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{D(t.target.files[0],e=>{M=e,S.src=e,A.src=e,k.textContent="New logo ready to save (both themes)"})});function y(t){E.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){x=t||I,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function b(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,S.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,H.src=e.customLogo,S.src=e.customLogo,A.src=e.customLogo),e.isDefault||(k.textContent="Using custom logo"),e.position&&(O=e.position,y(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{O=t.dataset.pos,y(O)})}),document.getElementById("brand")?.addEventListener("click",()=>{L=null,j=null,M=null,P&&(P.value=""),g&&(g.value=""),T&&(T.value=""),N&&(N.checked=!1),R.style.display="flex",z.style.display="none",S.src=w.src,A.src=H.src;const t=w.src.includes("custom-logo")||H.src.includes("custom-logo");k.textContent=t?"Using custom logo":"Using default logos",y(O),$.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||I,e={position:O,dashboardTitle:t};N?.checked&&M?(e.dataDark=M,e.dataLight=M):(L&&(e.dataDark=L),j&&(e.dataLight=j));const o=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(o.ok){const a=await o.json(),d="?t="+Date.now();a.pathDark&&(w.src=a.pathDark+d,S.src=a.pathDark+d),a.pathLight&&(H.src=a.pathLight+d,A.src=a.pathLight+d),y(O),m(t),h.classList.remove("show")}else{const a=await o.json();showNotification("Failed to save: "+a.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? -This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(b.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",P.src="/assets/dashcaddy-logo-light.png",w.textContent="Using default logos",T=null,j=null,H=null,B.value=S,u(S),R="left",g("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const o=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),r=document.getElementById("favicon-status");o&&(o.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),r&&(r.textContent="Using DashCaddy favicon"),i=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const m=document.getElementById("favicon-preview"),p=document.getElementById("favicon-status"),d=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let i=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function $(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const e=await n.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),m.src=e.customFavicon+"?t="+Date.now(),p.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}d?.addEventListener("change",n=>{const e=n.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),d.value="";return}const o=new FileReader;o.onload=a=>{i=a.target.result,m.src=i,p.textContent="New favicon ready to save"},o.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(i)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:i})});if(n.ok){const e=await n.json();c.href=e.path+"?t="+Date.now(),m.src=e.path+"?t="+Date.now(),p.textContent="Using custom favicon",i=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),$(),f();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{h.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const o=await fetch("/api/v1/config");o.ok&&(e=(await o.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const o=await fetch("/api/v1/config");if(!o.ok)return;const a=await o.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(o){console.warn("Failed to save timezone:",o.message)}}))})(),window.populateTimezoneSelect=function(h,E){const P=Intl.supportedValuesOf("timeZone"),w=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of P){const O=document.createElement("option");O.value=N,O.textContent=N.replace(/_/g," "),N===w&&(O.selected=!0),h.appendChild(O)}},(function(){let h="homelab",E=null;async function P(){try{const D=await fetch("/api/v1/config");if(D.ok&&(E=await D.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}P();const w=document.getElementById("setup-timezone");w&&window.populateTimezoneSelect(w);function N(x){document.querySelectorAll(".setup-step").forEach(g=>{g.style.display="none"});const D=document.getElementById(x);D&&(D.style.display="block")}function O(){const x=document.getElementById("setup-summary-content");if(!x)return;let D='
';if(h==="homelab"){const u=document.getElementById("setup-tld")?.value?.trim()||".home",f=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=` +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",S.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",k.textContent="Using default logos",L=null,j=null,M=null,$.value=I,m(I),O="left",y("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const o=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),d=document.getElementById("favicon-status");o&&(o.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),d&&(d.textContent="Using DashCaddy favicon"),r=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const p=document.getElementById("favicon-preview"),v=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),l=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(l.rel="icon",l.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(l));async function f(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(l.href=e.customFavicon+"?t="+Date.now(),p.src=e.customFavicon+"?t="+Date.now(),v.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const o=new FileReader;o.onload=a=>{r=a.target.result,p.src=r,v.textContent="New favicon ready to save"},o.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(t.ok){const e=await t.json();l.href=e.path+"?t="+Date.now(),p.src=e.path+"?t="+Date.now(),v.textContent="Using custom favicon",r=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),f(),b();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{h.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const o=await fetch("/api/v1/config");o.ok&&(e=(await o.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const o=await fetch("/api/v1/config");if(!o.ok)return;const a=await o.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(o){console.warn("Failed to save timezone:",o.message)}}))})(),window.populateTimezoneSelect=function(h,S){const A=Intl.supportedValuesOf("timeZone"),k=S||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of A){const R=document.createElement("option");R.value=N,R.textContent=N.replace(/_/g," "),N===k&&(R.selected=!0),h.appendChild(R)}},(function(){let h="homelab",S=null;async function A(){try{const D=await fetch("/api/v1/config");if(D.ok&&(S=await D.json(),S&&S.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const k=document.getElementById("setup-timezone");k&&window.populateTimezoneSelect(k);function N(x){document.querySelectorAll(".setup-step").forEach(y=>{y.style.display="none"});const D=document.getElementById(x);D&&(D.style.display="block")}function R(){const x=document.getElementById("setup-summary-content");if(!x)return;let D='
';if(h==="homelab"){const m=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",v=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=`

Home Lab Configuration

-
TLD: ${u}
-
Certificate Authority: ${f}
-
DNS Server: ${m}:${p}
-
Example URLs: https://uptime${u}, https://nextcloud${u}
+
TLD: ${m}
+
Certificate Authority: ${b}
+
DNS Server: ${p}:${v}
+
Example URLs: https://uptime${m}, https://nextcloud${m}
- `}else if(h==="simple"){const u=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=` + `}else if(h==="simple"){const m=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=`

Simple Setup

Access Method: IP:Port only
-
Default IP: ${u}
+
Default IP: ${m}
SSL: None (HTTP only)
-
Example URLs: http://${u}:8080, http://${u}:3000
+
Example URLs: http://${m}:8080, http://${m}:3000
- `}else if(h==="public"){const u=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"",m=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=m==="subdirectory"?`https://${u}/sonarr, https://${u}/grafana`:`https://sonarr.${u}, https://grafana.${u}`;D+=` + `}else if(h==="public"){const m=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",v=p==="subdirectory"?`https://${m}/sonarr, https://${m}/grafana`:`https://sonarr.${m}, https://grafana.${m}`;D+=`

Public Server

-
Domain: ${u}
+
Domain: ${m}
SSL: Let's Encrypt
-
Email: ${f}
-
Routing: ${m==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}
-
Example URLs: ${p}
+
Email: ${b}
+
Routing: ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}
+
Example URLs: ${v}
- `}const g=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=` + `}const y=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=`
-
Timezone: ${g.replace(/_/g," ")}
+
Timezone: ${y.replace(/_/g," ")}
- `,D+="
",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const f=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:f,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()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.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(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-public")});const R=document.getElementById("setup-finish");R&&(R.onclick=function(x){x.preventDefault(),A()}),window.getGlobalConfig=async function(){try{const D=await fetch("/api/v1/config");if(D.ok){const g=await D.json();if(g&&g.setupComplete)return g}}catch{console.warn("Could not fetch config from server")}const x=safeGet("dashcaddy-config");return x?JSON.parse(x):{setupComplete:!1,configurationType:"homelab",tld:".home",caName:"",defaults:{dnsType:"private",sslType:"internal",targetIP:"localhost"}}},window.resetSetupWizard=async function(){if(confirm("Reset DashCaddy configuration? This will show the setup wizard again.")){try{await secureFetch("/api/v1/config",{method:"DELETE"})}catch{console.warn("Could not delete server config")}safeRemove("dashcaddy-setup"),safeRemove("dashcaddy-config"),location.reload()}}})(),(function(){const h=new ErrorHandler;injectModal("app-selector-modal",`
+ `,D+="
",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function P(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:b,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const y=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",m=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${y}. Settings saved to: ${m}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(x){const D=x.target.value||".home",y=document.getElementById("tld-preview"),m=document.getElementById("tld-preview-2");y&&(y.textContent=D),m&&(m.textContent=D)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(x){x.preventDefault(),N("setup-step-1")});const E=document.getElementById("setup-homelab-next");E&&(E.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",y=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!y){showNotification("Please enter a Certificate Authority name","warning");return}if(!m){showNotification("Please enter your DNS server IP address","warning");return}R()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(x){x.preventDefault(),N("setup-step-1")});const I=document.getElementById("setup-simple-next");I&&(I.onclick=function(x){x.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const L=document.getElementById("setup-public-back");L&&(L.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!y||!y.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-public")});const O=document.getElementById("setup-finish");O&&(O.onclick=function(x){x.preventDefault(),P()}),window.getGlobalConfig=async function(){try{const D=await fetch("/api/v1/config");if(D.ok){const y=await D.json();if(y&&y.setupComplete)return y}}catch{console.warn("Could not fetch config from server")}const x=safeGet("dashcaddy-config");return x?JSON.parse(x):{setupComplete:!1,configurationType:"homelab",tld:".home",caName:"",defaults:{dnsType:"private",sslType:"internal",targetIP:"localhost"}}},window.resetSetupWizard=async function(){if(confirm("Reset DashCaddy configuration? This will show the setup wizard again.")){try{await secureFetch("/api/v1/config",{method:"DELETE"})}catch{console.warn("Could not delete server config")}safeRemove("dashcaddy-setup"),safeRemove("dashcaddy-config"),location.reload()}}})(),(function(){const h=new ErrorHandler;injectModal("app-selector-modal",`

Choose an App

@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
-
`);const E="custom-apps";let P=null,w=null;const N=document.getElementById("app-selector-modal"),O=document.getElementById("app-selector-grid");async function z(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return P=c.templates,w=c.categories,!0}catch(d){h.logError("[AppSelector] Fetch Templates",d,{function:"fetchApiTemplates"})}return!1}async function A(d){try{return await(await fetch(`/api/v1/apps/ports/${d}/check`)).json()}catch(c){return h.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(d){try{const i=await(await fetch(`/api/v1/apps/ports/${d}/suggest`)).json();if(i.success)return i.suggestedPort}catch(c){h.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return d}async function L(){if(O.innerHTML='
Loading app templates...
',!P&&!await z()){O.innerHTML='
Failed to load app templates. Please try again.
';return}O.innerHTML="";const d={};for(const[i,$]of Object.entries(P)){const y=$.category||"Other";d[y]||(d[y]=[]),d[y].push({id:i,...$})}const c=w?Object.keys(w):Object.keys(d).sort();for(const i of c){const $=d[i];if(!$||$.length===0)continue;$.sort((e,o)=>(o.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const n=w?.[i]||{};y.innerHTML=`${escapeHtml(n.icon||"")} ${escapeHtml(i)}`,n.color&&(y.style.borderBottomColor=n.color),O.appendChild(y),$.forEach(e=>{const o=document.createElement("div");o.className="app-option";const a=e.isDashboardWidget,r=a&&safeGet("widget-"+e.id+"-enabled")!=="false",t=a?`
${r?"ON":"OFF"}
`:"",s=!a&&e.difficulty?`
${escapeHtml(e.difficulty)}
`:"";o.innerHTML=` + `);const S="custom-apps";let A=null,k=null;const N=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function z(){try{const l=await(await fetch("/api/v1/apps/templates")).json();if(l.success)return A=l.templates,k=l.categories,!0}catch(s){h.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function P(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(l){return h.logError("[AppSelector] Check Port",l,{function:"checkPortAvailability"}),{available:!0}}}async function g(s){try{const r=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(r.success)return r.suggestedPort}catch(l){h.logError("[AppSelector] Get Suggested Port",l,{function:"getSuggestedPort"})}return s}async function T(){if(R.innerHTML='
Loading app templates...
',!A&&!await z()){R.innerHTML='
Failed to load app templates. Please try again.
';return}R.innerHTML="";const s={};for(const[r,f]of Object.entries(A)){const u=f.category||"Other";s[u]||(s[u]=[]),s[u].push({id:r,...f})}const l=k?Object.keys(k):Object.keys(s).sort();for(const r of l){const f=s[r];if(!f||f.length===0)continue;f.sort((e,o)=>(o.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const t=k?.[r]||{};u.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(r)}`,t.color&&(u.style.borderBottomColor=t.color),R.appendChild(u),f.forEach(e=>{const o=document.createElement("div");o.className="app-option";const a=e.isDashboardWidget,d=a&&safeGet("widget-"+e.id+"-enabled")!=="false",n=a?`
${d?"ON":"OFF"}
`:"",i=!a&&e.difficulty?`
${escapeHtml(e.difficulty)}
`:"";o.innerHTML=`
${escapeHtml(e.icon||"\u{1F4E6}")}
${escapeHtml(e.name)}
${escapeHtml(e.description||"")}
- ${t}${s} - `,a?o.onclick=()=>b(e,o):o.onclick=()=>M(e),O.appendChild(o)})}window.renderRecipeCards&&await window.renderRecipeCards(O)}function b(d,c){const i="widget-"+d.id+"-enabled",y=!(safeGet(i)!=="false");safeSet(i,String(y));const n=d.widgetSelector;if(n){const o=document.querySelector(n);o&&(o.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${d.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function M(d){const c=document.getElementById("app-deploy-modal"),i=document.getElementById("app-deploy-title"),$=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),n=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),o=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:d.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${d.name} container: + ${n}${i} + `,a?o.onclick=()=>w(e,o):o.onclick=()=>H(e),R.appendChild(o)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function w(s,l){const r="widget-"+s.id+"-enabled",u=!(safeGet(r)!=="false");safeSet(r,String(u));const t=s.widgetSelector;if(t){const o=document.querySelector(t);o&&(o.style.display=u?"":"none")}const e=l.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=u?"ON":"OFF",e.style.background=u?"#2ecc7130":"#e74c3c30",e.style.color=u?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function H(s){const l=document.getElementById("app-deploy-modal"),r=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),o=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${s.name} container: Container: ${V.name} Status: ${V.status} @@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"} Would you like to use this existing container? Click OK to configure DNS/Caddy for the existing container. -Click Cancel to deploy a new container.`)&&(d._useExisting=!0,d._existingContainer=V)}}catch{}i.textContent=`Deploy ${d.name}`;const r=d.subdomain||d.id.replace(/-/g,"");$.value=r;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const _=d.subpathSupport||"strip";_==="none"?(t.style.display="block",t.innerHTML=''+d.name+" does not support subdirectory mode. It may not work correctly at a subpath."):_==="strip"?(t.style.display="block",t.innerHTML='ⓘ '+d.name+" has unverified subdirectory support. It may require additional configuration."):t.style.display="none"}else t.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),C=document.querySelector(`input[name="dns-type"][value="${s}"]`),I=document.querySelector(`input[name="ssl-type"][value="${l}"]`);C?C.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,I?I.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,n.value=SITE.defaults.targetIP||"localhost",o.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(d.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const le=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`${escapeHtml(ee.folderName)}
from ${escapeHtml(ee.sourceImage)}`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const de=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=de.indexOf(ee.hostPath);pe>=0?(de.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(de.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=de.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(d.id==="plex"||d.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",d.docker?.volumes?.length){const _=d.mediaMount?.containerPath,W=d.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=` +Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}r.textContent=`Deploy ${s.name}`;const d=s.subdomain||s.id.replace(/-/g,"");f.value=d;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const _=s.subpathSupport||"strip";_==="none"?(n.style.display="block",n.innerHTML=''+s.name+" does not support subdirectory mode. It may not work correctly at a subpath."):_==="strip"?(n.style.display="block",n.innerHTML='ⓘ '+s.name+" has unverified subdirectory support. It may require additional configuration."):n.style.display="none"}else n.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),C=document.querySelector(`input[name="dns-type"][value="${i}"]`),B=document.querySelector(`input[name="ssl-type"][value="${c}"]`);C?C.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,B?B.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",o.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const le=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`${escapeHtml(ee.folderName)}
from ${escapeHtml(ee.sourceImage)}`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const de=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=de.indexOf(ee.hostPath);pe>=0?(de.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(de.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=de.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const _=s.mediaMount?.containerPath,W=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=` \u2192 ${ee} - `,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const le=Z.querySelector(".vol-host-path");openFolderBrowser(le)}})):ne.style.display="none"}else ne.style.display="none";const se=d.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='Checking port...';const W=await A(_);if(W.available)Y.innerHTML=`Port ${escapeHtml(String(_))} is available`;else{const V=await v(se);Y.innerHTML=` + `,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const le=Z.querySelector(".vol-host-path");openFolderBrowser(le)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='Checking port...';const W=await P(_);if(W.available)Y.innerHTML=`Port ${escapeHtml(String(_))} is available`;else{const V=await g(se);Y.innerHTML=` Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")} `;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`Using suggested port ${escapeHtml(String(V))}`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=` Connected ${W.self?.hostname} (${W.self?.ip}) | ${W.deviceCount} devices - `:W.installed?a.innerHTML='Not connected':(a.innerHTML='Not available',o.disabled=!0)}catch{a.innerHTML='Could not check status'}function ae(){const _=$.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||d.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${n.value}:${te}`}y.textContent=K}$.oninput=ae,n.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(d)}async function k(d){const c=d.appTemplate,i=safeGetJSON(E,[]),$=c._useExisting&&c._existingContainer,y=i.find(n=>n.id===d.subdomain);if(!(y&&!$&&!confirm(`An app with subdomain "${d.subdomain}" already exists. Redeploy?`))){if(y){const n=i.indexOf(y);i.splice(n,1),safeSet(E,JSON.stringify(i))}if($)d.port=c._existingContainer.primaryPort;else{const n=d.port||c.defaultPort||8080;showNotification(`Checking port ${n} availability...`,"info",0);const e=await A(n);if(!e.available){const o=await v(c.defaultPort||8080);if(confirm(`Port ${n} is already in use by ${e.conflict?.usedBy||"another container"}. + `:W.installed?a.innerHTML='Not connected':(a.innerHTML='Not available',o.disabled=!0)}catch{a.innerHTML='Could not check status'}function ae(){const _=f.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}u.textContent=K}f.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),l.classList.add("show"),l.dataset.appTemplate=JSON.stringify(s)}async function E(s){const l=s.appTemplate,r=safeGetJSON(S,[]),f=l._useExisting&&l._existingContainer,u=r.find(t=>t.id===s.subdomain);if(!(u&&!f&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(u){const t=r.indexOf(u);r.splice(t,1),safeSet(S,JSON.stringify(r))}if(f)s.port=l._existingContainer.primaryPort;else{const t=s.port||l.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await P(t);if(!e.available){const o=await g(l.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}. -Would you like to use port ${o} instead?`))d.port=o;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification($?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const n={appId:c.id,config:{subdomain:d.subdomain,ip:d.ip,createDns:d.dnsType==="private",port:d.port||c.defaultPort||null,sslType:d.sslType,dnsType:d.dnsType,tailscaleOnly:d.tailscaleOnly||!1,mediaPath:d.mediaPath||null,plexClaimToken:d.plexClaimToken||null,customVolumes:d.customVolumes||null}};$&&(n.config.useExisting=!0,n.config.existingContainerId=c._existingContainer.id,n.config.existingPort=c._existingContainer.primaryPort,!d.port&&c._existingContainer.primaryPort&&(n.config.port=c._existingContainer.primaryPort));const o=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();if(o.success){const a={id:d.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:o.containerId,url:o.url,ip:d.ip,appTemplate:c.id,tailscaleOnly:d.tailscaleOnly||!1};i.push(a),safeSet(E,JSON.stringify(i)),window.APPS&&!window.APPS.some(t=>t.id===c.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let r=o.usedExisting?`${c.name} configured with existing container! -URL: ${o.url}`:`${c.name} deployed successfully! -URL: ${o.url}`;o.warning&&(r+=` +Would you like to use port ${o} instead?`))s.port=o;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${l.name} with existing container...`:`Deploying ${l.name}...`,"info",0);try{const t={appId:l.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||l.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};f&&(t.config.useExisting=!0,t.config.existingContainerId=l._existingContainer.id,t.config.existingPort=l._existingContainer.primaryPort,!s.port&&l._existingContainer.primaryPort&&(t.config.port=l._existingContainer.primaryPort));const o=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(o.success){const a={id:s.subdomain,name:l.name,logo:`/assets/${l.id}.png`,containerId:o.containerId,url:o.url,ip:s.ip,appTemplate:l.id,tailscaleOnly:s.tailscaleOnly||!1};r.push(a),safeSet(S,JSON.stringify(r)),window.APPS&&!window.APPS.some(n=>n.id===l.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let d=o.usedExisting?`${l.name} configured with existing container! +URL: ${o.url}`:`${l.name} deployed successfully! +URL: ${o.url}`;o.warning&&(d+=` -\u26A0 Warning: ${o.warning}`),showNotification(r,"success",8e3),delete c._useExisting,delete c._existingContainer,o.url&&o.url.startsWith("https://")&&B(o.url,c.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const t=o.setupInstructions.join(` -`);showNotification(`Setup Instructions for ${c.name}: ${t}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(n){h.logError("[AppSelector] Deployment",n,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${n.message}`,"error",8e3)}}}async function B(d,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let i=0;const $=12,y=async()=>{i++;try{const n=await fetch(d,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return i<$?setTimeout(y,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating. -Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(y,3e3)}function S(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(i=>i.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),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 d=JSON.parse(T.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),i=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{i.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const $={appTemplate:d,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:i.length>0?i:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!$.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(d.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),k($)}),wireModal(T);const j=document.getElementById("folder-browser-modal"),H=document.getElementById("folder-browser-path"),R=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let g="",u=[],f=null;window.openFolderBrowser=function(d){f=d,u=d.value.split(",").map(c=>c.trim()).filter(c=>c),g="",p(),m(""),j.classList.add("show")};async function m(d){H.textContent=d||"Select a drive...",R.innerHTML='
Loading...
';try{const i=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(d)}`)).json();if(!i.success){R.innerHTML=`
Error: ${escapeHtml(i.error)}
`;return}g=i.path||"",H.textContent=g||"Select a drive...";let $="";i.parent&&i.parent!==i.path&&($+=`
+\u26A0 Warning: ${o.warning}`),showNotification(d,"success",8e3),delete l._useExisting,delete l._existingContainer,o.url&&o.url.startsWith("https://")&&$(o.url,l.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const n=o.setupInstructions.join(` +`);showNotification(`Setup Instructions for ${l.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(t){h.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${l.name}: ${t.message}`,"error",8e3)}}}async function $(s,l){showNotification(`\u23F3 Generating SSL certificate for ${l}...`,"warning",6e4);let r=0;const f=12,u=async()=>{r++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${l} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return r{window.APPS.some(r=>r.id===l.id)||window.APPS.push(l)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const L=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(L.dataset.appTemplate),l=document.getElementById("deploy-media-path").value.trim(),r=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{r.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const f={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:l||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:r.length>0?r: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(s.mediaMount?.required&&!l){showNotification("Please enter a media library path for this application","warning");return}L.classList.remove("show"),E(f)}),wireModal(L);const j=document.getElementById("folder-browser-modal"),M=document.getElementById("folder-browser-path"),O=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let y="",m=[],b=null;window.openFolderBrowser=function(s){b=s,m=s.value.split(",").map(l=>l.trim()).filter(l=>l),y="",v(),p(""),j.classList.add("show")};async function p(s){M.textContent=s||"Select a drive...",O.innerHTML='
Loading...
';try{const r=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!r.success){O.innerHTML=`
Error: ${escapeHtml(r.error)}
`;return}y=r.path||"",M.textContent=y||"Select a drive...";let f="";r.parent&&r.parent!==r.path&&(f+=`
\u2B06\uFE0F .. Parent Directory -
`),i.items.length===0&&!i.parent?$+='
No browseable drives configured. Check your docker-compose.yml volume mounts.
':i.items.length===0?$+='
No subfolders found
':i.items.forEach(y=>{const n=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=u.includes(y.path),o=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";$+=`
- ${n} - ${escapeHtml(y.name)} +
`),r.items.length===0&&!r.parent?f+='
No browseable drives configured. Check your docker-compose.yml volume mounts.
':r.items.length===0?f+='
No subfolders found
':r.items.forEach(u=>{const t=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(u.path),o=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`
+ ${t} + ${escapeHtml(u.name)} ${e?'\u2713':""} -
`}),R.innerHTML=$,R.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{m(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const n=u.includes(y.dataset.path);y.style.background=n?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){R.innerHTML=`
Failed to load: ${escapeHtml(c.message)}
`}}function p(){if(u.length===0){x.style.display="none";return}x.style.display="block",D.innerHTML=u.map(d=>` +
`}),O.innerHTML=f,O.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{p(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const t=m.includes(u.dataset.path);u.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(l){O.innerHTML=`
Failed to load: ${escapeHtml(l.message)}
`}}function v(){if(m.length===0){x.style.display="none";return}x.style.display="block",D.innerHTML=m.map(s=>` - ${escapeHtml(d)} - + ${escapeHtml(s)} + - `).join("")}window.removeSelectedFolder=function(d){u=u.filter(c=>c!==d),p(),m(g)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{g&&!u.includes(g)&&(u.push(g),p(),m(g))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{f&&(f.value=u.join(", ")),j.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`
+ `).join("")}window.removeSelectedFolder=function(s){m=m.filter(l=>l!==s),v(),p(y)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{y&&!m.includes(y)&&(m.push(y),v(),p(y))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{b&&(b.value=m.join(", ")),j.classList.remove("show")}),I()})(),(function(){injectModal("recipe-deploy-modal",`

Deploy Recipe

@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
-
`);let h=null,E=null,P=null,w=1,N=!1;const O=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(O,z);async function L(){try{const g=await fetch("/api/v1/recipes/templates"),u=await g.json();if(u.success)return h=u.templates,E=u.categories,!0;if(g.status===403)return N=!1,!1}catch(g){console.warn("Failed to fetch recipe templates:",g.message)}return!1}async function b(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(g){await b();let u;if(N&&h?u=h:u=M(),!u||u.length===0)return;const f=document.createElement("div");f.className="app-category-header",f.innerHTML="\u{1F9EA} Recipes",f.style.borderBottomColor="#8e44ad",g.appendChild(f);const m=Array.isArray(u)?u:Object.values(u);m.sort((p,d)=>(d.popularity||0)-(p.popularity||0));for(const p of m){const d=document.createElement("div");d.className="app-option",d.style.position="relative";const c=`
${p.componentCount||p.components?.length||"?"} apps
`,i=N?"":'
PREMIUM
';d.innerHTML=` - ${i} -
${escapeHtml(p.icon||"\u{1F9EA}")}
-
${escapeHtml(p.name)}
-
${escapeHtml(p.description||"")}
- ${c} - `,d.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(p)},g.appendChild(d)}};function M(){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(g){P=g,w=1;const u=document.getElementById("app-selector-modal");u&&u.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${g.name}`,B(),S(),O.classList.add("show")}function B(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(g=>{const u=parseInt(g.dataset.step);g.classList.toggle("active",u===w),g.classList.toggle("completed",u1&&w<4?"":"none",w===4?(v.style.display="none",z.textContent="Close"):w===3?(v.textContent="\u{1F680} Deploy",v.style.display="",z.textContent="Cancel"):(v.textContent="Next",v.style.display="",z.textContent="Cancel")}function S(){const g=document.getElementById("recipe-component-list");g.innerHTML="";const u=P.components||[];for(const f of u){const m=document.createElement("div");m.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const p=f.required,d=f.internal;m.innerHTML=` - `);let h=null,S=null,A=null,k=1,N=!1;const R=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),P=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(R,z);async function T(){try{const y=await fetch("/api/v1/recipes/templates"),m=await y.json();if(m.success)return h=m.templates,S=m.categories,!0;if(y.status===403)return N=!1,!1}catch(y){console.warn("Failed to fetch recipe templates:",y.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(y){await w();let m;if(N&&h?m=h:m=H(),!m||m.length===0)return;const b=document.createElement("div");b.className="app-category-header",b.innerHTML="\u{1F9EA} Recipes",b.style.borderBottomColor="#8e44ad",y.appendChild(b);const p=Array.isArray(m)?m:Object.values(m);p.sort((v,s)=>(s.popularity||0)-(v.popularity||0));for(const v of p){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const l=`
${v.componentCount||v.components?.length||"?"} apps
`,r=N?"":'
PREMIUM
';s.innerHTML=` + ${r} +
${escapeHtml(v.icon||"\u{1F9EA}")}
+
${escapeHtml(v.name)}
+
${escapeHtml(v.description||"")}
+ ${l} + `,s.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}E(v)},y.appendChild(s)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function E(y){A=y,k=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${y.name}`,$(),I(),R.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(y=>{const m=parseInt(y.dataset.step);y.classList.toggle("active",m===k),y.classList.toggle("completed",m1&&k<4?"":"none",k===4?(g.style.display="none",z.textContent="Close"):k===3?(g.textContent="\u{1F680} Deploy",g.style.display="",z.textContent="Cancel"):(g.textContent="Next",g.style.display="",z.textContent="Cancel")}function I(){const y=document.getElementById("recipe-component-list");y.innerHTML="";const m=A.components||[];for(const b of m){const p=document.createElement("div");p.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const v=b.required,s=b.internal;p.innerHTML=` +
-
${escapeHtml(f.role||f.id)}
+
${escapeHtml(b.role||b.id)}
- ${f.templateRef?escapeHtml(f.templateRef):"Built-in"} - ${p?'Required':'Optional'} - ${d?'(Internal)':""} + ${b.templateRef?escapeHtml(b.templateRef):"Built-in"} + ${v?'Required':'Optional'} + ${s?'(Internal)':""}
- ${f.note?`
\u26A0 ${escapeHtml(f.note)}
`:""} + ${b.note?`
\u26A0 ${escapeHtml(b.note)}
`:""}
- `,g.appendChild(m)}}function T(){const g=document.getElementById("recipe-volumes-section"),u=document.getElementById("recipe-volume-list"),f=P.sharedVolumes;if(f&&Object.keys(f).length>0){g.style.display="",u.innerHTML="";for(const[m,p]of Object.entries(f)){const d=document.createElement("div");d.style.cssText="display: grid; gap: 4px;",d.innerHTML=` - - 0){y.style.display="",m.innerHTML="";for(const[p,v]of Object.entries(b)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=` + + -
${escapeHtml(p.description||"")}
- `,u.appendChild(d)}}else g.style.display="none"}function j(){const g=document.getElementById("recipe-review-content"),u=H(),f=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};f.forEach(i=>{m[i.dataset.volumeKey]=i.value});const p=document.getElementById("recipe-timezone").value||"UTC",d=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;g.innerHTML=` -
${escapeHtml(P.name)}
-
${escapeHtml(P.description||"")}
+
${escapeHtml(v.description||"")}
+ `,m.appendChild(s)}}else y.style.display="none"}function j(){const y=document.getElementById("recipe-review-content"),m=M(),b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};b.forEach(r=>{p[r.dataset.volumeKey]=r.value});const v=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",l=document.getElementById("recipe-tailscale").checked;y.innerHTML=` +
${escapeHtml(A.name)}
+
${escapeHtml(A.description||"")}
- Components (${u.length}): + Components (${m.length}):
- ${u.map(i=>`
- \u2022 ${escapeHtml(i.role||i.id)} ${i.internal?'(internal)':""} + ${m.map(r=>`
+ \u2022 ${escapeHtml(r.role||r.id)} ${r.internal?'(internal)':""}
`).join("")}
- ${Object.keys(m).length>0?`
+ ${Object.keys(p).length>0?`
Volumes: - ${Object.entries(m).map(([i,$])=>`
${i}: ${escapeHtml($)}
`).join("")} + ${Object.entries(p).map(([r,f])=>`
${r}: ${escapeHtml(f)}
`).join("")}
`:""}
- Timezone: ${escapeHtml(p)} • IP: ${escapeHtml(d)} ${c?"• Tailscale only":""} + Timezone: ${escapeHtml(v)} • IP: ${escapeHtml(s)} ${l?"• Tailscale only":""}
- ${P.network?`
Docker network: ${escapeHtml(P.network.name)}
`:""} - `}function H(){const g=document.querySelectorAll("#recipe-component-list input[data-component-id]"),u=new Set;g.forEach(m=>{m.checked&&u.add(m.dataset.componentId)});const f=P.components||[];return f.filter(m=>m.required).forEach(m=>u.add(m.id)),f.filter(m=>u.has(m.id))}async function R(){const g=document.getElementById("recipe-progress-list"),u=document.getElementById("recipe-deploy-result");u.style.display="none",g.innerHTML="";const f=H();for(const c of f){const i=document.createElement("div");i.id=`recipe-progress-${c.id}`,i.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;",i.innerHTML=` + ${A.network?`
Docker network: ${escapeHtml(A.network.name)}
`:""} + `}function M(){const y=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;y.forEach(p=>{p.checked&&m.add(p.dataset.componentId)});const b=A.components||[];return b.filter(p=>p.required).forEach(p=>m.add(p.id)),b.filter(p=>m.has(p.id))}async function O(){const y=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",y.innerHTML="";const b=M();for(const l of b){const r=document.createElement("div");r.id=`recipe-progress-${l.id}`,r.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;",r.innerHTML=` \u23F3 - ${escapeHtml(c.role||c.id)} + ${escapeHtml(l.role||l.id)} Queued - `,g.appendChild(i)}const m=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};m.forEach(c=>{p[c.dataset.volumeKey]=c.value});const d={selectedComponents:f.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:p},componentOverrides:{}};for(const c of f)x(c.id,"deploying","Deploying...");try{const i=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:P.id,config:d})})).json();if(i.success){for(const $ of i.deployed||[])x($.id,"success",$.url?`Running \u2192 ${$.url}`:"Running");for(const $ of i.errors||[])x($.componentId,"error",$.error);u.style.display="",u.innerHTML=` + `,y.appendChild(r)}const p=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};p.forEach(l=>{v[l.dataset.volumeKey]=l.value});const s={selectedComponents:b.map(l=>l.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:v},componentOverrides:{}};for(const l of b)x(l.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:s})})).json();if(r.success){for(const f of r.deployed||[])x(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of r.errors||[])x(f.componentId,"error",f.error);m.style.display="",m.innerHTML=`
-
${escapeHtml(i.message||"Deployed!")}
- ${i.setupInstructions?`
+
${escapeHtml(r.message||"Deployed!")}
+ ${r.setupInstructions?`
Setup tips: -
    ${i.setupInstructions.map($=>`
  • ${escapeHtml($)}
  • `).join("")}
+
    ${r.setupInstructions.map(f=>`
  • ${escapeHtml(f)}
  • `).join("")}
`:""}
- `,showNotification(`${P.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else u.style.display="",u.innerHTML=`
- Deployment failed: ${escapeHtml(i.error||"Unknown error")} -
`,showNotification(`Recipe deployment failed: ${i.error}`,"error",5e3)}catch(c){u.style.display="",u.innerHTML=`
- Network error: ${escapeHtml(c.message)} -
`}}function x(g,u,f){const m=document.getElementById(`recipe-progress-${g}`);if(!m)return;const p=m.querySelector(".recipe-progress-icon"),d=m.querySelector(".recipe-progress-status");u==="deploying"?(p.textContent="\u23F3",d.style.color="var(--accent)"):u==="success"?(p.textContent="\u2705",d.style.color="var(--ok-fg)"):u==="error"&&(p.textContent="\u274C",d.style.color="var(--bad-fg)"),d.textContent=f}v.addEventListener("click",()=>{if(w===3){w=4,B(),R();return}w<3&&(w++,B(),w===2&&T(),w===3&&j())}),A.addEventListener("click",()=>{w>1&&w<4&&(w--,B())}),window.groupRecipeCards=function(){const g=document.querySelectorAll(".service-card[data-recipe-id]");if(g.length===0)return;const u={};g.forEach(f=>{const m=f.dataset.recipeId;u[m]||(u[m]=[]),u[m].push(f)});for(const[f,m]of Object.entries(u))m.length<2||m.forEach((p,d)=>{if(p.style.borderLeft="3px solid rgba(142,68,173,0.5)",d===0){let c=p.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=f.replace(/-/g," "),p.style.position="relative",p.appendChild(c))}})},window.manageRecipe=async function(g,u){const f=`/api/v1/recipes/${g}/${u}`,m=u==="remove"?"DELETE":"POST",p=u==="remove"?`/api/v1/recipes/${g}`:f;if(!(u==="remove"&&!confirm(`Remove the entire ${g} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(p,{method:m})).json();c.success?(showNotification(`Recipe ${u}: ${c.results?.filter(i=>i.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${u} failed: ${c.error}`,"error",5e3)}catch(d){showNotification(`Network error: ${d.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=` + `,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`
+ Deployment failed: ${escapeHtml(r.error||"Unknown error")} +
`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(l){m.style.display="",m.innerHTML=`
+ Network error: ${escapeHtml(l.message)} +
`}}function x(y,m,b){const p=document.getElementById(`recipe-progress-${y}`);if(!p)return;const v=p.querySelector(".recipe-progress-icon"),s=p.querySelector(".recipe-progress-status");m==="deploying"?(v.textContent="\u23F3",s.style.color="var(--accent)"):m==="success"?(v.textContent="\u2705",s.style.color="var(--ok-fg)"):m==="error"&&(v.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=b}g.addEventListener("click",()=>{if(k===3){k=4,$(),O();return}k<3&&(k++,$(),k===2&&L(),k===3&&j())}),P.addEventListener("click",()=>{k>1&&k<4&&(k--,$())}),window.groupRecipeCards=function(){const y=document.querySelectorAll(".service-card[data-recipe-id]");if(y.length===0)return;const m={};y.forEach(b=>{const p=b.dataset.recipeId;m[p]||(m[p]=[]),m[p].push(b)});for(const[b,p]of Object.entries(m))p.length<2||p.forEach((v,s)=>{if(v.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let l=v.querySelector(".recipe-group-label");l||(l=document.createElement("div"),l.className="recipe-group-label",l.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;",l.textContent=b.replace(/-/g," "),v.style.position="relative",v.appendChild(l))}})},window.manageRecipe=async function(y,m){const b=`/api/v1/recipes/${y}/${m}`,p=m==="remove"?"DELETE":"POST",v=m==="remove"?`/api/v1/recipes/${y}`:b;if(!(m==="remove"&&!confirm(`Remove the entire ${y} recipe? This will delete all containers and configuration.`)))try{const l=await(await secureFetch(v,{method:p})).json();l.success?(showNotification(`Recipe ${m}: ${l.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${l.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=` .recipe-step { flex: 1; text-align: center; @@ -550,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}}; .recipe-step-panel { min-height: 180px; } - `,document.head.appendChild(D),b()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),E=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const P=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),w=await P.json();if(P.ok&&w.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3);else throw new Error(w.error||"Reload failed")}catch(P){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${P.message}`,"error"),setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'

\u{1F4CB} Error Logs

Loading error logs...
');const h=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),P=document.getElementById("view-error-logs"),w=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),O=document.getElementById("error-log-close");async function z(){E.innerHTML='
Loading error logs...
';try{const L=await(await fetch("/api/v1/error-logs")).json();L.success&&L.logs?L.logs.length===0?E.innerHTML='
\u2705 No errors logged! Everything is working smoothly.
':E.innerHTML=L.logs.map(b=>` + `,document.head.appendChild(D),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),S=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),k=await A.json();if(A.ok&&k.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3);else throw new Error(k.error||"Reload failed")}catch(A){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'

\u{1F4CB} Error Logs

Loading error logs...
');const h=document.getElementById("error-log-modal"),S=document.getElementById("error-log-content"),A=document.getElementById("view-error-logs"),k=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function z(){S.innerHTML='
Loading error logs...
';try{const T=await(await fetch("/api/v1/error-logs")).json();T.success&&T.logs?T.logs.length===0?S.innerHTML='
\u2705 No errors logged! Everything is working smoothly.
':S.innerHTML=T.logs.map(w=>`
- ${new Date(b.timestamp).toLocaleString()} + ${new Date(w.timestamp).toLocaleString()} ERROR
- ${escapeHtml(b.context)}: ${escapeHtml(b.error)} - ${b.details?`
${escapeHtml(b.details)}`:""} + ${escapeHtml(w.context)}: ${escapeHtml(w.error)} + ${w.details?`
${escapeHtml(w.details)}`:""}
- `).join(""):E.innerHTML='
\u274C Failed to load error logs
'}catch(v){E.innerHTML=`
\u274C Error loading logs: ${escapeHtml(v.message)}
`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}P?.addEventListener("click",()=>{h.classList.add("show"),z()}),w?.addEventListener("click",z),N?.addEventListener("click",A),wireModal(h,O)})(),(function(){injectModal("container-logs-modal",`
+ `).join(""):S.innerHTML='
\u274C Failed to load error logs
'}catch(g){S.innerHTML=`
\u274C Error loading logs: ${escapeHtml(g.message)}
`}}async function P(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}A?.addEventListener("click",()=>{h.classList.add("show"),z()}),k?.addEventListener("click",z),N?.addEventListener("click",P),wireModal(h,R)})(),(function(){injectModal("container-logs-modal",`
@@ -609,14 +609,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
-
`);const h=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),P=document.getElementById("cl-log-content"),w=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),O=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),A=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),b=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),B=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),H=document.getElementById("cl-status"),R=document.getElementById("cl-created");let x=null,D=[],g=[],u=null,f=!1,m=null;function p(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function d(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const C=s.stream==="stderr"?"log-stderr":"log-stdout",I=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return` -
- ${l+1} - ${I} - ${d(s.text)} +
`);const h=document.getElementById("container-logs-modal"),S=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),k=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),P=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),E=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),I=document.getElementById("cl-line-count"),L=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),M=document.getElementById("cl-status"),O=document.getElementById("cl-created");let x=null,D=[],y=[],m=null,b=!1,p=null;function v(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function s(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function l(i,c){const C=i.stream==="stderr"?"log-stderr":"log-stdout",B=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return` +
+ ${c+1} + ${B} + ${s(i.text)}
- `}function i(s,l=""){if(!s||s.length===0){P.innerHTML='
No logs available
',S.textContent="0 lines",T.textContent="0 filtered";return}if(D=s,g=l?s.filter(C=>C.text&&C.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,T.textContent=l?`${g.length} of ${s.length} shown`:`${s.length} shown`,g.length===0){P.innerHTML=`
No logs match "${d(l)}"
`;return}P.innerHTML=g.map((C,I)=>c(C,I)).join(""),P.scrollTop=P.scrollHeight}async function $(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],C=E.value;E.innerHTML='',l.forEach(I=>{const F=document.createElement("option");F.value=I.id,F.textContent=`${I.name} (${I.image.split(":")[0]}) - ${I.status}`,F.dataset.name=I.name,F.dataset.image=I.image,F.dataset.status=I.status,F.dataset.created=I.created,E.appendChild(F)}),C&&E.querySelector(`option[value="${C}"]`)&&(E.value=C,y(C))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=E.querySelector(`option[value="${s}"]`);l&&(j.textContent=l.dataset.image||"-",H.textContent=l.dataset.status||"-",H.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",R.textContent=p(l.dataset.created))}async function n(){const s=E.value;if(!s){P.innerHTML='
Select a container to view logs
';return}o(),x=s,y(s);const l=N.value,C=w.value.trim();P.innerHTML='
Loading logs...
';try{const I=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(I);F.logs&&F.logs.length>0?i(F.logs,C):(P.innerHTML='
No logs found for this container
',S.textContent="0 lines",T.textContent="0 filtered")}catch(I){P.innerHTML=`
Error loading logs: ${d(I.message)}
`}}function e(){const s=E.value;if(!s)return;o(),f=!0,z.textContent="\u23F9 Stop",M.style.display="flex",k.textContent="\u{1F7E2}",B.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;u=new EventSource(l),u.onopen=()=>{k.textContent="\u{1F7E2}",B.textContent="Connected - streaming logs"},u.onmessage=C=>{try{const I=JSON.parse(C.data);if(I.error){k.textContent="\u{1F534}",B.textContent=`Error: ${I.error}`;return}D.push(I),g.push(I),S.textContent=`${D.length} lines`,T.textContent=`${g.length} shown`;const F=w.value.trim();if(!F||I.text&&I.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(I,g.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",P.appendChild(U),P.scrollTop=P.scrollHeight}}catch(I){console.error("Error parsing log:",I)}},u.onerror=()=>{k.textContent="\u{1F534}",B.textContent="Disconnected",f=!1,z.textContent="\u25B6 Stream"},h._eventSource=u}function o(){u&&(u.close(),u=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),f=!1,z.textContent="\u25B6 Stream",M.style.display="none"}function a(){if(!D||D.length===0){showNotification("No logs to download","error");return}const s=E.querySelector(`option[value="${x}"]`)?.dataset.name||x,l=new Date().toISOString().replace(/[:.]/g,"-"),C=`${s}-logs-${l}.txt`,I=D.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(` -`),F=new Blob([I],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=C,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${D.length} log lines`,"success")}E?.addEventListener("change",()=>{n()}),N?.addEventListener("change",()=>{n()}),O?.addEventListener("click",()=>{n()}),z?.addEventListener("click",()=>{f?o():e()}),A?.addEventListener("click",()=>{a()}),v?.addEventListener("click",()=>{w.value="",i(D,"")}),w?.addEventListener("input",()=>{clearTimeout(m),m=setTimeout(()=>{i(D,w.value.trim())},300)}),w?.addEventListener("keydown",s=>{s.key==="Escape"&&(w.value="",i(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),$()});function t(){o(),h.classList.remove("show")}L?.addEventListener("click",t),b?.addEventListener("click",t),document.addEventListener("keydown",s=>{s.key==="Escape"&&h.classList.contains("show")&&t()}),h.addEventListener("click",s=>{s.target===h&&t()}),window.openContainerLogsModal=function(s,l){h.classList.add("show"),$().then(()=>{const C=Array.from(E.options).find(I=>I.value===s||I.dataset.name===l);C?(E.value=C.value,y(C.value),n()):s?(x=s,j.textContent=l||s,H.textContent="-",R.textContent="-",n()):P.innerHTML='
Select a container to view logs
'})}})(),(function(){injectModal("snapshot-modal",`
+ `}function r(i,c=""){if(!i||i.length===0){A.innerHTML='
No logs available
',I.textContent="0 lines",L.textContent="0 filtered";return}if(D=i,y=c?i.filter(C=>C.text&&C.text.toLowerCase().includes(c.toLowerCase())):i,I.textContent=`${i.length} lines`,L.textContent=c?`${y.length} of ${i.length} shown`:`${i.length} shown`,y.length===0){A.innerHTML=`
No logs match "${s(c)}"
`;return}A.innerHTML=y.map((C,B)=>l(C,B)).join(""),A.scrollTop=A.scrollHeight}async function f(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],C=S.value;S.innerHTML='',c.forEach(B=>{const F=document.createElement("option");F.value=B.id,F.textContent=`${B.name} (${B.image.split(":")[0]}) - ${B.status}`,F.dataset.name=B.name,F.dataset.image=B.image,F.dataset.status=B.status,F.dataset.created=B.created,S.appendChild(F)}),C&&S.querySelector(`option[value="${C}"]`)&&(S.value=C,u(C))}catch(i){console.error("Failed to load containers:",i)}}function u(i){const c=S.querySelector(`option[value="${i}"]`);c&&(j.textContent=c.dataset.image||"-",M.textContent=c.dataset.status||"-",M.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",O.textContent=v(c.dataset.created))}async function t(){const i=S.value;if(!i){A.innerHTML='
Select a container to view logs
';return}o(),x=i,u(i);const c=N.value,C=k.value.trim();A.innerHTML='
Loading logs...
';try{const B=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,F=await getJSON(B);F.logs&&F.logs.length>0?r(F.logs,C):(A.innerHTML='
No logs found for this container
',I.textContent="0 lines",L.textContent="0 filtered")}catch(B){A.innerHTML=`
Error loading logs: ${s(B.message)}
`}}function e(){const i=S.value;if(!i)return;o(),b=!0,z.textContent="\u23F9 Stop",H.style.display="flex",E.textContent="\u{1F7E2}",$.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;m=new EventSource(c),m.onopen=()=>{E.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},m.onmessage=C=>{try{const B=JSON.parse(C.data);if(B.error){E.textContent="\u{1F534}",$.textContent=`Error: ${B.error}`;return}D.push(B),y.push(B),I.textContent=`${D.length} lines`,L.textContent=`${y.length} shown`;const F=k.value.trim();if(!F||B.text&&B.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=l(B,y.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",A.appendChild(U),A.scrollTop=A.scrollHeight}}catch(B){console.error("Error parsing log:",B)}},m.onerror=()=>{E.textContent="\u{1F534}",$.textContent="Disconnected",b=!1,z.textContent="\u25B6 Stream"},h._eventSource=m}function o(){m&&(m.close(),m=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),b=!1,z.textContent="\u25B6 Stream",H.style.display="none"}function a(){if(!D||D.length===0){showNotification("No logs to download","error");return}const i=S.querySelector(`option[value="${x}"]`)?.dataset.name||x,c=new Date().toISOString().replace(/[:.]/g,"-"),C=`${i}-logs-${c}.txt`,B=D.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(` +`),F=new Blob([B],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=C,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${D.length} log lines`,"success")}S?.addEventListener("change",()=>{t()}),N?.addEventListener("change",()=>{t()}),R?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{b?o():e()}),P?.addEventListener("click",()=>{a()}),g?.addEventListener("click",()=>{k.value="",r(D,"")}),k?.addEventListener("input",()=>{clearTimeout(p),p=setTimeout(()=>{r(D,k.value.trim())},300)}),k?.addEventListener("keydown",i=>{i.key==="Escape"&&(k.value="",r(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),f()});function n(){o(),h.classList.remove("show")}T?.addEventListener("click",n),w?.addEventListener("click",n),document.addEventListener("keydown",i=>{i.key==="Escape"&&h.classList.contains("show")&&n()}),h.addEventListener("click",i=>{i.target===h&&n()}),window.openContainerLogsModal=function(i,c){h.classList.add("show"),f().then(()=>{const C=Array.from(S.options).find(B=>B.value===i||B.dataset.name===c);C?(S.value=C.value,u(C.value),t()):i?(x=i,j.textContent=c||i,M.textContent="-",O.textContent="-",t()):A.innerHTML='
Select a container to view logs
'})}})(),(function(){injectModal("snapshot-modal",`

\u{1F4BE} Container Snapshots

-
`);const h=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),P=document.getElementById("snapshot-close"),w=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),O=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let A=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;w.innerHTML='';for(const T of S.containers){const j=document.createElement("option");j.value=T.id,j.textContent=`${T.name||T.id} (${T.image||"unknown"})`,j.dataset.name=T.name,j.dataset.image=T.image,j.dataset.status=T.status,j.dataset.created=T.created,w.appendChild(j)}}catch(B){console.error("Failed to load containers:",B)}}function L(B){if(!B||!B.value){N.style.display="none",A=null;return}A=B.value,document.getElementById("snapshot-image").textContent=B.dataset.image||"-",document.getElementById("snapshot-status").textContent=B.dataset.status||"-",document.getElementById("snapshot-created").textContent=B.dataset.created?new Date(B.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=B.value.substring(0,12),N.style.display=""}async function b(){if(!A){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const B=document.getElementById("snapshot-name").value.trim();if(!B){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;O.disabled=!0,O.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(A)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,leaveRunning:S})})).json();j.success?(z.textContent=`\u2713 Snapshot "${B}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(T){z.textContent=`\u2717 Error: ${T.message}`,z.style.color="var(--bad-fg)"}finally{O.disabled=!1,O.textContent="\u{1F4BE} Create Snapshot"}}function M(){h.classList.add("show"),v()}function k(){h.classList.remove("show"),N.style.display="none",A=null,w.selectedIndex=0}E?.addEventListener("click",M),P?.addEventListener("click",k),wireModal(h,P),w?.addEventListener("change",B=>{const S=w.options[w.selectedIndex];L(S)}),O?.addEventListener("click",b),h?.querySelectorAll(".panel-tab").forEach(B=>{B.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),B.classList.add("active"),h.querySelector(`#${B.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`
+
`);const h=document.getElementById("snapshot-modal"),S=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),k=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let P=null;async function g(){try{const I=await(await fetch("/api/v1/containers")).json();if(!I.success||!I.containers)return;k.innerHTML='';for(const L of I.containers){const j=document.createElement("option");j.value=L.id,j.textContent=`${L.name||L.id} (${L.image||"unknown"})`,j.dataset.name=L.name,j.dataset.image=L.image,j.dataset.status=L.status,j.dataset.created=L.created,k.appendChild(j)}}catch($){console.error("Failed to load containers:",$)}}function T($){if(!$||!$.value){N.style.display="none",P=null;return}P=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),N.style.display=""}async function w(){if(!P){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const I=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(P)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:I})})).json();j.success?(z.textContent=`\u2713 Snapshot "${$}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(L){z.textContent=`\u2717 Error: ${L.message}`,z.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function H(){h.classList.add("show"),g()}function E(){h.classList.remove("show"),N.style.display="none",P=null,k.selectedIndex=0}S?.addEventListener("click",H),A?.addEventListener("click",E),wireModal(h,A),k?.addEventListener("change",$=>{const I=k.options[k.selectedIndex];T(I)}),R?.addEventListener("click",w),h?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(I=>I.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(I=>I.classList.remove("active")),$.classList.add("active"),h.querySelector(`#${$.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`

\u{1F3AC} Smart Arr Connect

@@ -749,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};

-
`);const h=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),P=document.getElementById("arr-setup-cancel"),w=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),O=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),b=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),B=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}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function H(m){N.style.display=m==="detect"?"block":"none",O.style.display=m==="credentials"?"block":"none",z.style.display=m==="progress"?"block":"none",A.style.display=m==="results"?"block":"none"}function R(m){const p={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},d=p[m]||p.not_found;return`${d.icon} ${d.text}`}async function x(){H("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`
Detection failed: ${escapeHtml(S.error)}
`,v.style.display="block";return}let p='
';for(const[c,i]of Object.entries(S.services)){const $=T[c]||"\u{1F4E6}",y=j[c]||c,n=i.source?`${escapeHtml(i.source)}`:"",e=i.version?`v${escapeHtml(i.version)}`:"",o=(i.hasApiKey||i.hasToken)&&i.status==="connected"?'Key saved':"";p+=`
- ${$} +
`);const h=document.getElementById("arr-setup-modal"),S=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),k=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),P=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),E=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let I=null;const L={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(p){N.style.display=p==="detect"?"block":"none",R.style.display=p==="credentials"?"block":"none",z.style.display=p==="progress"?"block":"none",P.style.display=p==="results"?"block":"none"}function O(p){const v={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},s=v[p]||v.not_found;return`${s.icon} ${s.text}`}async function x(){M("detect"),g.style.display="none";try{if(I=await(await fetch("/api/v1/arr/smart-detect")).json(),!I.success){g.innerHTML=`
Detection failed: ${escapeHtml(I.error)}
`,g.style.display="block";return}let v='
';for(const[l,r]of Object.entries(I.services)){const f=L[l]||"\u{1F4E6}",u=j[l]||l,t=r.source?`${escapeHtml(r.source)}`:"",e=r.version?`v${escapeHtml(r.version)}`:"",o=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'Key saved':"";v+=`
+ ${f}
-
${y}
+
${u}
- ${n} ${e} ${o} + ${t} ${e} ${o}
- ${R(i.status)} -
`}p+="
";const d=S.summary;p+=`
- ${escapeHtml(String(d.fullyConnected))}/${escapeHtml(String(d.totalDetected+(5-d.totalDetected)))} services detected · - ${escapeHtml(String(d.fullyConnected))} connected${d.needsApiKey>0?` · ${escapeHtml(String(d.needsApiKey))} needs API key`:""} -
`,v.innerHTML=p,v.style.display="block",D(S),setTimeout(()=>{H("credentials")},800)}catch(m){v.innerHTML=`
Error: ${escapeHtml(m.message)}
`,v.style.display="block"}}function D(m){let p="";const d=m.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const n=d[y];if(!n||n.status==="not_found"&&!n.url)continue;const e=T[y],o=j[y],a=n.status==="connected";p+=`
+ ${O(r.status)} +
`}v+="
";const s=I.summary;v+=`
+ ${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected · + ${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` · ${escapeHtml(String(s.needsApiKey))} needs API key`:""} +
`,g.innerHTML=v,g.style.display="block",D(I),setTimeout(()=>{M("credentials")},800)}catch(p){g.innerHTML=`
Error: ${escapeHtml(p.message)}
`,g.style.display="block"}}function D(p){let v="";const s=p.services,l=["radarr","sonarr","prowlarr"];for(const u of l){const t=s[u];if(!t||t.status==="not_found"&&!t.url)continue;const e=L[u],o=j[u],a=t.status==="connected";v+=`
${e} ${o} - + ${a?'✓ Connected':""}
-
-
- -
`}const i=d.plex;if(i){const y=i.status==="connected";p+=`
+ +
`}const r=s.plex;if(r){const u=r.status==="connected";v+=`
\u{1F3AC} Plex - ${R(i.status)} - ${escapeHtml(i.source||"")} + ${O(r.status)} + ${escapeHtml(r.source||"")}
-
`}const $=d.seerr;if($){const y=$.status==="connected";let n="";if($.configuredServices){const e=$.configuredServices;n=`
+
`}const f=s.seerr;if(f){const u=f.status==="connected";let t="";if(f.configuredServices){const e=f.configuredServices;t=`
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} · ${e.sonarr?"✓ Sonarr":"✗ Sonarr"} · ${e.plex?"✓ Plex":"✗ Plex"} -
`}p+=`
+
`}v+=`
\u{1F4CB} Seerr - ${R($.status)} + ${O(f.status)}
- ${n} -
`}L.innerHTML=p}window.smartTestConnection=async function(m){const p=document.getElementById(`smart-${m}-url`),d=document.getElementById(`smart-${m}-key`),c=document.getElementById(`smart-${m}-status`),i=p?.value.trim(),$=d?.value.trim();if(!i||!$){c.innerHTML='Enter URL and API key';return}c.innerHTML='';try{const n=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:m,url:i,apiKey:$})})).json();n.success?c.innerHTML=`✓ ${escapeHtml(n.appName||"Connected")} v${escapeHtml(n.version||"")}`:c.innerHTML=`✗ ${escapeHtml(n.error)}`}catch(y){c.innerHTML=`✗ ${escapeHtml(y.message)}`}};async function g(){H("progress"),b.innerHTML='
Connecting services...
';const m={};for(const d of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${d}-url`)?.value.trim(),i=document.getElementById(`smart-${d}-key`)?.value.trim();i&&c?m[d]={apiKey:i,url:c}:i&&(m[d]={apiKey:i})}const p={services:Object.keys(m).length>0?m: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(p)})).json();let i="";for(const $ of c.steps||[]){const y=$.status==="success"?'':'',n=$.status==="success"?"var(--muted)":"var(--bad-fg)";i+=`
- ${y} - ${escapeHtml($.step)} - ${escapeHtml($.details||"")} -
`}b.innerHTML=i,setTimeout(()=>u(c),500)}catch(d){b.innerHTML=`
Connection error: ${escapeHtml(d.message)}
`}}function u(m){H("results");const p=m.summary||{},d=p.failed===0&&p.succeeded>0,c=d?"var(--ok-fg)":"#f39c12",i=d?"✓":"⚠",$=d?"All Connected!":`${escapeHtml(String(p.succeeded))}/${escapeHtml(String(p.totalSteps))} Steps Succeeded`;let y=`
-
${i}
-
${$}
-
${escapeHtml(String(p.succeeded))} succeeded, ${escapeHtml(String(p.failed))} failed
-
`;y+='
';for(const n of m.steps||[]){const e=n.status==="success"?'':'';y+=`
- ${e} ${escapeHtml(n.step)} ${escapeHtml(n.details||"")} -
`}y+="
",M.innerHTML=y,B.style.display=p.failed>0?"block":"none",m.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&f()}async function f(){try{const p=await(await fetch("/api/v1/plex/libraries")).json();if(p.success&&p.libraries?.length>0){let d=`
-

\u{1F3AC} ${escapeHtml(p.serverName)} Libraries

-
`;for(const c of p.libraries){const i=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";d+=`
- ${i} ${escapeHtml(c.title)} - ${escapeHtml(String(c.count))} items -
`}d+="
",k.innerHTML=d,k.style.display="block"}}catch{}}E?.addEventListener("click",()=>{h.classList.add("show"),k.style.display="none",x()}),wireModal(h,P),w?.addEventListener("click",g),B?.addEventListener("click",g)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`
+ ${t} +
`}T.innerHTML=v}window.smartTestConnection=async function(p){const v=document.getElementById(`smart-${p}-url`),s=document.getElementById(`smart-${p}-key`),l=document.getElementById(`smart-${p}-status`),r=v?.value.trim(),f=s?.value.trim();if(!r||!f){l.innerHTML='Enter URL and API key';return}l.innerHTML='';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:p,url:r,apiKey:f})})).json();t.success?l.innerHTML=`✓ ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}`:l.innerHTML=`✗ ${escapeHtml(t.error)}`}catch(u){l.innerHTML=`✗ ${escapeHtml(u.message)}`}};async function y(){M("progress"),w.innerHTML='
Connecting services...
';const p={};for(const s of["radarr","sonarr","prowlarr"]){const l=document.getElementById(`smart-${s}-url`)?.value.trim(),r=document.getElementById(`smart-${s}-key`)?.value.trim();r&&l?p[s]={apiKey:r,url:l}:r&&(p[s]={apiKey:r})}const v={services:Object.keys(p).length>0?p: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 l=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)})).json();let r="";for(const f of l.steps||[]){const u=f.status==="success"?'':'',t=f.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`
+ ${u} + ${escapeHtml(f.step)} + ${escapeHtml(f.details||"")} +
`}w.innerHTML=r,setTimeout(()=>m(l),500)}catch(s){w.innerHTML=`
Connection error: ${escapeHtml(s.message)}
`}}function m(p){M("results");const v=p.summary||{},s=v.failed===0&&v.succeeded>0,l=s?"var(--ok-fg)":"#f39c12",r=s?"✓":"⚠",f=s?"All Connected!":`${escapeHtml(String(v.succeeded))}/${escapeHtml(String(v.totalSteps))} Steps Succeeded`;let u=`
+
${r}
+
${f}
+
${escapeHtml(String(v.succeeded))} succeeded, ${escapeHtml(String(v.failed))} failed
+
`;u+='
';for(const t of p.steps||[]){const e=t.status==="success"?'':'';u+=`
+ ${e} ${escapeHtml(t.step)} ${escapeHtml(t.details||"")} +
`}u+="
",H.innerHTML=u,$.style.display=v.failed>0?"block":"none",p.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&b()}async function b(){try{const v=await(await fetch("/api/v1/plex/libraries")).json();if(v.success&&v.libraries?.length>0){let s=`
+

\u{1F3AC} ${escapeHtml(v.serverName)} Libraries

+
`;for(const l of v.libraries){const r=l.type==="movie"?"\u{1F3AC}":l.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`
+ ${r} ${escapeHtml(l.title)} + ${escapeHtml(String(l.count))} items +
`}s+="
",E.innerHTML=s,E.style.display="block"}}catch{}}S?.addEventListener("click",()=>{h.classList.add("show"),E.style.display="none",x()}),wireModal(h,A),k?.addEventListener("click",y),$?.addEventListener("click",y)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`

\u{1F514} Notification Settings

@@ -1000,15 +1000,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
-
`);const E=document.getElementById("notifications-modal"),P=document.getElementById("manage-notifications"),w=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const B=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);B?.addEventListener("change",()=>{S.style.display=B.checked?"block":"none"})});const O=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");O?.addEventListener("change",()=>{z.style.opacity=O.checked?"1":"0.5"});async function A(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const S=B.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(k){h.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function v(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");B.success&&B.history?.length>0?S.innerHTML=B.history.map(T=>{const j=new Date(T.timestamp).toLocaleString();return` +
`);const S=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),k=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(E=>{const $=document.getElementById(`${E}-enabled`),I=document.getElementById(`${E}-config`);$?.addEventListener("change",()=>{I.style.display=$.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");R?.addEventListener("change",()=>{z.style.opacity=R.checked?"1":"0.5"});async function P(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const I=$.config;document.getElementById("notifications-enabled").checked=I.enabled,document.getElementById("discord-enabled").checked=I.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=I.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=I.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=I.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=I.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=I.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=I.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=I.providers?.email?.enabled?"block":"none",I.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=I.providers.ntfy.serverUrl),I.providers?.email?.host&&(document.getElementById("email-host").value=I.providers.email.host),I.providers?.email?.from&&(document.getElementById("email-from").value=I.providers.email.from),document.getElementById("health-check-enabled").checked=I.healthCheck?.enabled||!1,I.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=I.healthCheck.intervalMinutes),I.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(I.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=I.events?.containerDown!==!1,document.getElementById("event-container-up").checked=I.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=I.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=I.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=I.events?.resourceAlert!==!1}}catch(E){h.logError("[Notifications] Load Config",E,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),I=document.getElementById("notification-history");$.success&&$.history?.length>0?I.innerHTML=$.history.map(L=>{const j=new Date(L.timestamp).toLocaleString();return`
- ${T.type==="success"?"\u2713":T.type==="error"?"\u2717":"\u2139"} + ${L.type==="success"?"\u2713":L.type==="error"?"\u2717":"\u2139"}
-
${escapeHtml(T.title)}
+
${escapeHtml(L.title)}
${j}
- `}).join(""):S.innerHTML='
No notifications yet
'}catch(k){h.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function L(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();S.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function b(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(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>b("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>b("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>b("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>b("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),P?.addEventListener("click",()=>{E.classList.add("show"),A(),v()}),w?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),B=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),M()):showNotification(`Test failed: ${T.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=B,k.disabled=!1}});async function M(){try{const B=await(await fetch("/api/v1/notifications/status")).json();if(B.success&&B.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(B.lastSent).toLocaleString()}`)}}catch{}}wireModal(E,N)})(),(function(){document.addEventListener("click",h=>{const E=h.target.closest(".panel-tab");if(!E)return;const P=E.dataset.panel;if(!P)return;const w=E.closest(".panel-tabs"),N=w.closest(".weather-modal-content");w.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),E.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const O=N.querySelector("#"+P);O&&O.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function E(){for(var e={},o=0;o + `}).join(""):I.innerHTML='
No notifications yet
'}catch(E){h.logError("[Notifications] Load History",E,{function:"loadHistory"})}}async function T(){try{const E={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},I=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();I.success?(showNotification("Notification settings saved","success",3e3),S.classList.remove("show")):showNotification(`Failed to save: ${I.error}`,"error",3e3)}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}}async function w(E){try{const I=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:E})})).json();I.success?showNotification(`Test ${E} notification sent!`,"success",3e3):showNotification(`Test failed: ${I.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.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 $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{S.classList.add("show"),P(),g()}),k?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const E=document.getElementById("notifications-send-test"),$=E.textContent;E.textContent="Sending...",E.disabled=!0;try{const L=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();L.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${L.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(I){showNotification(`Error: ${I.message}`,"error",3e3)}finally{E.textContent=$,E.disabled=!1}});async function H(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){const I=document.getElementById("last-notification-sent");I&&(I.textContent=`Last sent: ${new Date($.lastSent).toLocaleString()}`)}}catch{}}wireModal(S,N)})(),(function(){document.addEventListener("click",h=>{const S=h.target.closest(".panel-tab");if(!S)return;const A=S.dataset.panel;if(!A)return;const k=S.closest(".panel-tabs"),N=k.closest(".weather-modal-content");k.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),S.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const R=N.querySelector("#"+A);R&&R.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function S(){for(var e={},o=0;o

\u{1F4BE} Backup & Restore

-
`);var O=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),b=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),B=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),H=document.getElementById("backup-history-container"),R=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),D=null;z?.addEventListener("click",function(){O.classList.add("show"),T&&(T.style.display="none"),k&&(k.style.display="none"),M&&(M.style.display="none"),D=null}),wireModal(O,A),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML=' Exporting...';try{var e=await fetch("/api/v1/backup/export"),o=await e.json();o.browserState=E();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),r=URL.createObjectURL(a),t=document.createElement("a");t.href=r,t.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(t),t.click(),document.body.removeChild(t),URL.revokeObjectURL(r);var s=Object.keys(o.browserState).length,l=o.themes?Object.keys(o.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" 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(C){T.innerHTML="\u274C Export failed: "+escapeHtml(C.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)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){b.click()}),b?.addEventListener("change",async function(e){var o=e.target.files[0];if(o){M.textContent="\u{1F4C4} "+o.name,M.style.display="block",T.style.display="none";try{var a=await o.text(),r=JSON.parse(a);if(w(r)){D=r;var t='
Legacy format (v'+escapeHtml(r.version)+")
";t+='
',r.services?.length&&(t+='\u{1F4CB} '+r.services.length+" services"),r.customApps?.length&&(t+='\u{1F4E6} '+r.customApps.length+" custom apps"),r.theme&&(t+='\u{1F3A8} Theme: '+escapeHtml(r.theme)+""),r.userThemes&&(t+='\u{1F3A8} '+Object.keys(r.userThemes).length+" custom themes"),t+="
",B.innerHTML=t,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),l=await s.json();if(l.success){D=r;var t='
Exported: '+new Date(r.exportedAt).toLocaleString()+" (v"+escapeHtml(r.version)+")
";t+='
Server Config
',t+='
';for(var C in l.preview.files){var I=l.preview.files[C],F=I.action==="create"?"\u{1F195}":"\u{1F4DD}";t+=''+F+" "+escapeHtml(I.description)+""}t+="
",l.preview.serviceCount&&(t+='
'+l.preview.serviceCount+" services
"),l.preview.themeCount&&(t+='
\u{1F3A8} '+l.preview.themeCount+" custom themes
"),l.preview.browserStateCount&&(t+='
Browser Preferences
',t+='
\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)
"),B.innerHTML=t,k.style.display="block"}else T.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),T.style.display="block",T.style.background="color-mix(in srgb, #f39c12 15%, transparent)",T.style.border="1px solid #f39c12",k.style.display="none"}catch(q){T.innerHTML="\u274C Could not read file: "+escapeHtml(q.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)",k.style.display="none"}}}),S?.addEventListener("click",async function(){if(D&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){S.disabled=!0,S.innerHTML=' Restoring...';try{if(w(D)){N(D),T.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",T.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",T.style.border="1px solid var(--ok-fg)",T.style.display="block",setTimeout(function(){location.reload()},2e3),S.disabled=!1,S.innerHTML="\u26A1 Restore Everything";return}var e=document.getElementById("backup-reload-caddy")?.checked??!0,o=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:D,options:{reloadCaddy:e}})}),a=await o.json(),r=0;if(D.browserState&&(r=P(D.browserState)),a.success){var t="\u2705 "+a.message;r>0&&(t+='
'+r+" browser settings restored"),a.results.caddyReloaded&&(t+='
Caddy configuration reloaded'),T.innerHTML=t,T.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",T.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else T.innerHTML="\u26A0\uFE0F "+escapeHtml(a.message),r>0&&(T.innerHTML+='
'+r+" browser settings were restored"),a.results?.errors?.length>0&&(T.innerHTML+="
"+a.results.errors.map(function(s){return escapeHtml(s.file)+": "+escapeHtml(s.error)}).join(", ")+""),T.style.background="color-mix(in srgb, #f39c12 15%, transparent)",T.style.border="1px solid #f39c12";T.style.display="block"}catch(s){T.innerHTML="\u274C Restore failed: "+escapeHtml(s.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)"}S.disabled=!1,S.innerHTML="\u26A1 Restore Everything"}});async function g(){if(j){j.innerHTML='
Loading...
';try{var e=await fetch("/api/v1/backups/schedule"),o=await e.json();if(o.premiumRequired){j.innerHTML=`
\u2B50
Premium Feature
Auto-backup scheduling requires a DashCaddy Premium subscription.
`;return}if(!o.success)throw new Error(o.error||"Failed to load schedules");var a=o.schedules||[];if(a.length===0){j.innerHTML='
\u23F0
No backup schedules configured
Select apps below to enable auto-backup
';return}for(var r='
',t=0;t
Schedule:
Keep last:
Next run: '+escapeHtml(l)+"
Last run: "+escapeHtml(C)+'
'}r+="",r+='

\u2795 Add New Schedule

',j.innerHTML=r,j.querySelectorAll(".schedule-toggle").forEach(function(I){I.addEventListener("change",function(){u(I.dataset.appid,{enabled:I.checked})})}),j.querySelectorAll(".schedule-select").forEach(function(I){I.addEventListener("change",function(){u(I.dataset.appid,{schedule:I.value})})}),j.querySelectorAll(".retention-input").forEach(function(I){I.addEventListener("change",function(){u(I.dataset.appid,{retention:{keep:parseInt(I.value)||7}})})}),j.querySelectorAll(".schedule-run-now").forEach(function(I){I.addEventListener("click",function(){f(I.dataset.appid)})}),j.querySelectorAll(".schedule-delete").forEach(function(I){I.addEventListener("click",function(){m(I.dataset.appid)})}),document.getElementById("add-schedule-btn")?.addEventListener("click",p)}catch(I){j.innerHTML='
Failed to load: '+escapeHtml(I.message)+"
"}}}async function u(e,o){try{var a=await secureFetch("/api/v1/backups/schedule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e,...o})}),r=await a.json();r.success?showNotification("Schedule updated for "+e,"success"):(showNotification("Update failed: "+(r.error||"Unknown"),"error"),g())}catch(t){showNotification("Error: "+t.message,"error")}}async function f(e){try{var o=await secureFetch("/api/v1/backups/backup/"+encodeURIComponent(e),{method:"POST",headers:{"Content-Type":"application/json"}}),a=await o.json();a.success?showNotification("Backup started for "+e+"!","success"):showNotification("Backup failed: "+(a.error||"Unknown"),"error")}catch(r){showNotification("Error: "+r.message,"error")}}async function m(e){if(confirm("Remove backup schedule for "+e+"?"))try{var o=await secureFetch("/api/v1/backups/schedule/"+encodeURIComponent(e),{method:"DELETE"}),a=await o.json();a.success?(showNotification("Schedule removed for "+e,"success"),g()):showNotification("Delete failed: "+(a.error||"Unknown"),"error")}catch(r){showNotification("Error: "+r.message,"error")}}async function p(){var e=document.getElementById("new-schedule-appid")?.value?.trim(),o=document.getElementById("new-schedule-interval")?.value||"daily",a=parseInt(document.getElementById("new-schedule-retention")?.value)||7;if(!e){showNotification("Please enter an App ID","warning");return}try{var r=await secureFetch("/api/v1/backups/schedule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e,schedule:o,retention:{keep:a},enabled:!0})}),t=await r.json();if(t.success){showNotification("Schedule created for "+e,"success"),g();var s=document.getElementById("new-schedule-appid");s&&(s.value="")}else showNotification("Failed: "+(t.error||"Unknown"),"error")}catch(l){showNotification("Error: "+l.message,"error")}}async function d(){if(R){R.innerHTML='
Loading...
';try{var e=await fetch("/api/v1/backups/files"),o=await e.json();if(!o.success)throw new Error(o.error||"Failed to load");var a=o.files||[];if(a.length===0){R.innerHTML='
\u{1F4BE}
No backup files on disk
Run a backup to create backup files
';return}for(var r={},t=0;t";C+='
';for(var I=Object.keys(r).sort(),F=0;F
'+escapeHtml(l)+' ('+q.length+" backup(s))
";for(var U=0;U
'+s.sizeFormatted+'
'+G+'
'}C+=""}C+="",R.innerHTML=C,R.querySelectorAll(".disk-compare-btn").forEach(function(J){J.addEventListener("click",function(){y(J.dataset.appid,J.dataset.filename)})}),R.querySelectorAll(".disk-restore-btn").forEach(function(J){J.addEventListener("click",function(){n(J.dataset.appid,J.dataset.filename)})})}catch(J){R.innerHTML='
Failed: '+escapeHtml(J.message)+"
"}}}async function c(){if(H){H.innerHTML='
Loading...
';try{var e=await fetch("/api/v1/backups/history?limit=50"),o=await e.json();if(!o.success||!o.history?.length){H.innerHTML='
\u{1F4CB} No backup history yet
';return}for(var a='
',r=0;r',a+='
',a+=' '+escapeHtml(t.name||"backup")+"",a+='
',a+=' '+escapeHtml(t.status)+"",t.status==="success"&&(a+=' '),a+="
",a+="
",a+='
',a+=" "+new Date(t.timestamp).toLocaleString()+" | "+s+" MB | "+(t.duration?(t.duration/1e3).toFixed(1)+"s":"--"),t.encrypted&&(a+=" | \u{1F512}"),a+="
",a+="
"}a+="",H.innerHTML=a,H.querySelectorAll(".backup-restore-btn").forEach(function(l){l.addEventListener("click",function(){window.__restoreServerBackup(l.dataset.backupId)})})}catch(l){H.innerHTML='
Failed: '+escapeHtml(l.message)+"
"}}}window.__restoreServerBackup=async function(e){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var o=await secureFetch("/api/v1/backups/restore/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),a=await o.json();a.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(a.error||"Unknown error"),"error")}catch(r){showNotification("Restore error: "+r.message,"error")}},document.querySelector('[data-panel="backup-schedules-tab"]')?.addEventListener("click",g),document.querySelector('[data-panel="backup-disk-tab"]')?.addEventListener("click",d),document.querySelector('[data-panel="backup-pointintime-tab"]')?.addEventListener("click",i),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",c);async function i(){if(x){try{var e=await fetch("/api/v1/license/status"),o=await e.json();if(o.tier!=="premium"){x.innerHTML=`
\u2B50
Premium Feature
Point-in-time restore requires DashCaddy Premium with auto-backup enabled.
`;return}}catch{}x.innerHTML='
Loading...
';try{var a=await fetch("/api/v1/services"),r=await a.json(),t=r.services||[];if(t.length===0){x.innerHTML='
\u{1F4E6} No apps deployed yet
';return}for(var s='
',x.innerHTML=s,document.getElementById("pit-load-btn")?.addEventListener("click",function(){var C=document.getElementById("pit-app-select")?.value;C&&$(C)})}catch(C){x.innerHTML='
Failed: '+escapeHtml(C.message)+"
"}}}async function $(e){var o=document.getElementById("pit-backups-list");if(o){o.innerHTML='
Loading backups...
';try{var a=await fetch("/api/v1/backups/files/"+encodeURIComponent(e)),r=await a.json();if(!r.success||!r.files||r.files.length===0){o.innerHTML='
\u{1F4BE} No backup files for '+escapeHtml(e)+"
";return}for(var t='
'+r.files.length+' backup(s)
',s=0;s
'+l.sizeFormatted+'
'+C+'
'}t+="",o.innerHTML=t,o.querySelectorAll(".pit-compare-btn").forEach(function(I){I.addEventListener("click",function(){y(I.dataset.appid,I.dataset.filename)})}),o.querySelectorAll(".pit-restore-btn").forEach(function(I){I.addEventListener("click",function(){n(I.dataset.appid,I.dataset.filename)})})}catch(I){o.innerHTML='
Failed: '+escapeHtml(I.message)+"
"}}}async function y(e,o){try{var a=await secureFetch("/api/v1/backups/compare/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({})}),r=await a.json();if(!r.success){showNotification("Compare failed: "+(r.error||"Unknown"),"error");return}var t=r.diff,s='

\u{1F4CA} Compare: '+escapeHtml(o)+'

Size: '+(t.sizeFormatted||"?")+" | Created: "+new Date(t.timestamp).toLocaleString()+"
";if(t.services){var l=t.services.hasChanges?"\u{1F534}":"\u{1F7E2}";s+='
'+l+' Services (backup vs current)
Backup: '+t.services.backupCount+" services | Current: "+t.services.currentCount+" services
",t.services.hasChanges&&(s+='
Services differ \u2014 restoring will replace current configuration
'),s+="
"}if(t.config){var C=t.config.hasChanges?"\u{1F534}":"\u{1F7E2}";s+='
'+C+" Configuration
",t.config.hasChanges?s+='
Configuration differs \u2014 restoring will replace current settings
':s+='
No changes
',s+="
"}s+='
',document.body.insertAdjacentHTML("beforeend",s),document.getElementById("compare-close-btn")?.addEventListener("click",function(){document.getElementById("compare-overlay")?.remove()}),document.getElementById("compare-overlay")?.addEventListener("click",function(I){I.target===this&&this.remove()})}catch(I){showNotification("Compare error: "+I.message,"error")}}async function n(e,o){if(confirm("Restore "+o+" for "+e+`? + `);var R=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),P=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),E=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),I=document.getElementById("backup-do-restore-btn"),L=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),M=document.getElementById("backup-history-container"),O=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),D=null;z?.addEventListener("click",function(){R.classList.add("show"),L&&(L.style.display="none"),E&&(E.style.display="none"),H&&(H.style.display="none"),D=null}),wireModal(R,P),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML=' Exporting...';try{var e=await fetch("/api/v1/backup/export"),o=await e.json();o.browserState=S();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),d=URL.createObjectURL(a),n=document.createElement("a");n.href=d,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(d);var i=Object.keys(o.browserState).length,c=o.themes?Object.keys(o.themes).length:0;L.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),L.style.display="block",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)"}catch(C){L.innerHTML="\u274C Export failed: "+escapeHtml(C.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var o=e.target.files[0];if(o){H.textContent="\u{1F4C4} "+o.name,H.style.display="block",L.style.display="none";try{var a=await o.text(),d=JSON.parse(a);if(k(d)){D=d;var n='
Legacy format (v'+escapeHtml(d.version)+")
";n+='
',d.services?.length&&(n+='\u{1F4CB} '+d.services.length+" services"),d.customApps?.length&&(n+='\u{1F4E6} '+d.customApps.length+" custom apps"),d.theme&&(n+='\u{1F3A8} Theme: '+escapeHtml(d.theme)+""),d.userThemes&&(n+='\u{1F3A8} '+Object.keys(d.userThemes).length+" custom themes"),n+="
",$.innerHTML=n,E.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)}),c=await i.json();if(c.success){D=d;var n='
Exported: '+new Date(d.exportedAt).toLocaleString()+" (v"+escapeHtml(d.version)+")
";n+='
Server Config
',n+='
';for(var C in c.preview.files){var B=c.preview.files[C],F=B.action==="create"?"\u{1F195}":"\u{1F4DD}";n+=''+F+" "+escapeHtml(B.description)+""}n+="
",c.preview.serviceCount&&(n+='
'+c.preview.serviceCount+" services
"),c.preview.themeCount&&(n+='
\u{1F3A8} '+c.preview.themeCount+" custom themes
"),c.preview.browserStateCount&&(n+='
Browser Preferences
',n+='
\u{1F5A5}\uFE0F '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)
"),$.innerHTML=n,E.style.display="block"}else L.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),L.style.display="block",L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12",E.style.display="none"}catch(q){L.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)",E.style.display="none"}}}),I?.addEventListener("click",async function(){if(D&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){I.disabled=!0,I.innerHTML=' Restoring...';try{if(k(D)){N(D),L.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)",L.style.display="block",setTimeout(function(){location.reload()},2e3),I.disabled=!1,I.innerHTML="\u26A1 Restore Everything";return}var e=document.getElementById("backup-reload-caddy")?.checked??!0,o=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:D,options:{reloadCaddy:e}})}),a=await o.json(),d=0;if(D.browserState&&(d=A(D.browserState)),a.success){var n="\u2705 "+a.message;d>0&&(n+='
'+d+" browser settings restored"),a.results.caddyReloaded&&(n+='
Caddy configuration reloaded'),L.innerHTML=n,L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else L.innerHTML="\u26A0\uFE0F "+escapeHtml(a.message),d>0&&(L.innerHTML+='
'+d+" browser settings were restored"),a.results?.errors?.length>0&&(L.innerHTML+="
"+a.results.errors.map(function(i){return escapeHtml(i.file)+": "+escapeHtml(i.error)}).join(", ")+""),L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12";L.style.display="block"}catch(i){L.innerHTML="\u274C Restore failed: "+escapeHtml(i.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}I.disabled=!1,I.innerHTML="\u26A1 Restore Everything"}});async function y(){if(j){j.innerHTML='
Loading...
';try{var e=await fetch("/api/v1/backups/schedule"),o=await e.json();if(o.premiumRequired){j.innerHTML=`
\u2B50
Premium Feature
Auto-backup scheduling requires a DashCaddy Premium subscription.
`;return}if(!o.success)throw new Error(o.error||"Failed to load schedules");var a=o.schedules||[];if(a.length===0){j.innerHTML='
\u23F0
No backup schedules configured
Select apps below to enable auto-backup
';return}for(var d='
',n=0;n
Schedule:
Keep last:
Next run: '+escapeHtml(c)+"
Last run: "+escapeHtml(C)+'
'}d+="",d+='

\u2795 Add New Schedule

',j.innerHTML=d,j.querySelectorAll(".schedule-toggle").forEach(function(B){B.addEventListener("change",function(){m(B.dataset.appid,{enabled:B.checked})})}),j.querySelectorAll(".schedule-select").forEach(function(B){B.addEventListener("change",function(){m(B.dataset.appid,{schedule:B.value})})}),j.querySelectorAll(".retention-input").forEach(function(B){B.addEventListener("change",function(){m(B.dataset.appid,{retention:{keep:parseInt(B.value)||7}})})}),j.querySelectorAll(".schedule-run-now").forEach(function(B){B.addEventListener("click",function(){b(B.dataset.appid)})}),j.querySelectorAll(".schedule-delete").forEach(function(B){B.addEventListener("click",function(){p(B.dataset.appid)})}),document.getElementById("add-schedule-btn")?.addEventListener("click",v)}catch(B){j.innerHTML='
Failed to load: '+escapeHtml(B.message)+"
"}}}async function m(e,o){try{var a=await secureFetch("/api/v1/backups/schedule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e,...o})}),d=await a.json();d.success?showNotification("Schedule updated for "+e,"success"):(showNotification("Update failed: "+(d.error||"Unknown"),"error"),y())}catch(n){showNotification("Error: "+n.message,"error")}}async function b(e){try{var o=await secureFetch("/api/v1/backups/backup/"+encodeURIComponent(e),{method:"POST",headers:{"Content-Type":"application/json"}}),a=await o.json();a.success?showNotification("Backup started for "+e+"!","success"):showNotification("Backup failed: "+(a.error||"Unknown"),"error")}catch(d){showNotification("Error: "+d.message,"error")}}async function p(e){if(confirm("Remove backup schedule for "+e+"?"))try{var o=await secureFetch("/api/v1/backups/schedule/"+encodeURIComponent(e),{method:"DELETE"}),a=await o.json();a.success?(showNotification("Schedule removed for "+e,"success"),y()):showNotification("Delete failed: "+(a.error||"Unknown"),"error")}catch(d){showNotification("Error: "+d.message,"error")}}async function v(){var e=document.getElementById("new-schedule-appid")?.value?.trim(),o=document.getElementById("new-schedule-interval")?.value||"daily",a=parseInt(document.getElementById("new-schedule-retention")?.value)||7;if(!e){showNotification("Please enter an App ID","warning");return}try{var d=await secureFetch("/api/v1/backups/schedule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e,schedule:o,retention:{keep:a},enabled:!0})}),n=await d.json();if(n.success){showNotification("Schedule created for "+e,"success"),y();var i=document.getElementById("new-schedule-appid");i&&(i.value="")}else showNotification("Failed: "+(n.error||"Unknown"),"error")}catch(c){showNotification("Error: "+c.message,"error")}}async function s(){if(O){O.innerHTML='
Loading...
';try{var e=await fetch("/api/v1/backups/files"),o=await e.json();if(!o.success)throw new Error(o.error||"Failed to load");var a=o.files||[];if(a.length===0){O.innerHTML='
\u{1F4BE}
No backup files on disk
Run a backup to create backup files
';return}for(var d={},n=0;n";C+='
';for(var B=Object.keys(d).sort(),F=0;F
'+escapeHtml(c)+' ('+q.length+" backup(s))
";for(var U=0;U
'+i.sizeFormatted+'
'+G+'
'}C+=""}C+="",O.innerHTML=C,O.querySelectorAll(".disk-compare-btn").forEach(function(J){J.addEventListener("click",function(){u(J.dataset.appid,J.dataset.filename)})}),O.querySelectorAll(".disk-restore-btn").forEach(function(J){J.addEventListener("click",function(){t(J.dataset.appid,J.dataset.filename)})})}catch(J){O.innerHTML='
Failed: '+escapeHtml(J.message)+"
"}}}async function l(){if(M){M.innerHTML='
Loading...
';try{var e=await fetch("/api/v1/backups/history?limit=50"),o=await e.json();if(!o.success||!o.history?.length){M.innerHTML='
\u{1F4CB} No backup history yet
';return}for(var a='
',d=0;d',a+='
',a+=' '+escapeHtml(n.name||"backup")+"",a+='
',a+=' '+escapeHtml(n.status)+"",n.status==="success"&&(a+=' '),a+="
",a+="
",a+='
',a+=" "+new Date(n.timestamp).toLocaleString()+" | "+i+" MB | "+(n.duration?(n.duration/1e3).toFixed(1)+"s":"--"),n.encrypted&&(a+=" | \u{1F512}"),a+="
",a+="
"}a+="",M.innerHTML=a,M.querySelectorAll(".backup-restore-btn").forEach(function(c){c.addEventListener("click",function(){window.__restoreServerBackup(c.dataset.backupId)})})}catch(c){M.innerHTML='
Failed: '+escapeHtml(c.message)+"
"}}}window.__restoreServerBackup=async function(e){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var o=await secureFetch("/api/v1/backups/restore/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),a=await o.json();a.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(a.error||"Unknown error"),"error")}catch(d){showNotification("Restore error: "+d.message,"error")}},document.querySelector('[data-panel="backup-schedules-tab"]')?.addEventListener("click",y),document.querySelector('[data-panel="backup-disk-tab"]')?.addEventListener("click",s),document.querySelector('[data-panel="backup-pointintime-tab"]')?.addEventListener("click",r),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",l);async function r(){if(x){try{var e=await fetch("/api/v1/license/status"),o=await e.json();if(o.tier!=="premium"){x.innerHTML=`
\u2B50
Premium Feature
Point-in-time restore requires DashCaddy Premium with auto-backup enabled.
`;return}}catch{}x.innerHTML='
Loading...
';try{var a=await fetch("/api/v1/services"),d=await a.json(),n=d.services||[];if(n.length===0){x.innerHTML='
\u{1F4E6} No apps deployed yet
';return}for(var i='
',x.innerHTML=i,document.getElementById("pit-load-btn")?.addEventListener("click",function(){var C=document.getElementById("pit-app-select")?.value;C&&f(C)})}catch(C){x.innerHTML='
Failed: '+escapeHtml(C.message)+"
"}}}async function f(e){var o=document.getElementById("pit-backups-list");if(o){o.innerHTML='
Loading backups...
';try{var a=await fetch("/api/v1/backups/files/"+encodeURIComponent(e)),d=await a.json();if(!d.success||!d.files||d.files.length===0){o.innerHTML='
\u{1F4BE} No backup files for '+escapeHtml(e)+"
";return}for(var n='
'+d.files.length+' backup(s)
',i=0;i
'+c.sizeFormatted+'
'+C+'
'}n+="",o.innerHTML=n,o.querySelectorAll(".pit-compare-btn").forEach(function(B){B.addEventListener("click",function(){u(B.dataset.appid,B.dataset.filename)})}),o.querySelectorAll(".pit-restore-btn").forEach(function(B){B.addEventListener("click",function(){t(B.dataset.appid,B.dataset.filename)})})}catch(B){o.innerHTML='
Failed: '+escapeHtml(B.message)+"
"}}}async function u(e,o){try{var a=await secureFetch("/api/v1/backups/compare/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({})}),d=await a.json();if(!d.success){showNotification("Compare failed: "+(d.error||"Unknown"),"error");return}var n=d.diff,i='

\u{1F4CA} Compare: '+escapeHtml(o)+'

Size: '+(n.sizeFormatted||"?")+" | Created: "+new Date(n.timestamp).toLocaleString()+"
";if(n.services){var c=n.services.hasChanges?"\u{1F534}":"\u{1F7E2}";i+='
'+c+' Services (backup vs current)
Backup: '+n.services.backupCount+" services | Current: "+n.services.currentCount+" services
",n.services.hasChanges&&(i+='
Services differ \u2014 restoring will replace current configuration
'),i+="
"}if(n.config){var C=n.config.hasChanges?"\u{1F534}":"\u{1F7E2}";i+='
'+C+" Configuration
",n.config.hasChanges?i+='
Configuration differs \u2014 restoring will replace current settings
':i+='
No changes
',i+="
"}i+='
',document.body.insertAdjacentHTML("beforeend",i),document.getElementById("compare-close-btn")?.addEventListener("click",function(){document.getElementById("compare-overlay")?.remove()}),document.getElementById("compare-overlay")?.addEventListener("click",function(B){B.target===this&&this.remove()})}catch(B){showNotification("Compare error: "+B.message,"error")}}async function t(e,o){if(confirm("Restore "+o+" for "+e+`? -This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),r=await a.json();r.success?(showNotification(e+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(r.error||"Unknown"),"error")}catch(t){showNotification("Restore error: "+t.message,"error")}}})(),(function(){injectModal("stats-modal",`
+This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),d=await a.json();d.success?(showNotification(e+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(d.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`

\u{1F4CA} Resource Monitor

-
`);const h=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),P=document.getElementById("stats-cancel"),w=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),O=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let L=null,b=null;function M(i){if(i===0||!i)return"0 B";const $=1024,y=["B","KB","MB","GB"],n=Math.floor(Math.log(i)/Math.log($));return parseFloat((i/Math.pow($,n)).toFixed(1))+" "+y[n]}function k(i){return i<30?"#2ecc71":i<70?"#f39c12":"#e74c3c"}function B(i){return i<50?"#2ecc71":i<80?"#f39c12":"#e74c3c"}async function S(){try{let i=null,$=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(i=e.stats,$=!0,b=e.stats)}catch{}if(!$){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){i={};for(const o of e.stats)i[o.name]={name:o.name,current:{cpu:o.cpu,memory:{percent:o.memory.percent,usage:o.memory.used,limit:o.memory.limit,usageMB:Math.round(o.memory.used/1048576),limitMB:Math.round(o.memory.limit/1048576)},network:{rxBytes:o.network.rx,txBytes:o.network.tx,rxMB:(o.network.rx/1048576).toFixed(1),txMB:(o.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:o.status};b=i}}if(!i||Object.keys(i).length===0){O.innerHTML='
No running containers found
';return}let y='
';for(const[n,e]of Object.entries(i)){const o=e.current||e,a=o.cpu?.percent||0,r=o.memory?.percent||0,t=k(a),s=B(r),l=o.memory?.usage||o.memory?.used||0,C=o.memory?.limit||0,I=o.network?.rxBytes||o.network?.rx||0,F=o.network?.txBytes||o.network?.tx||0,q=e.aggregated;y+=` +
`);const h=document.getElementById("stats-modal"),S=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),k=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),P=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let T=null,w=null;function H(r){if(r===0||!r)return"0 B";const f=1024,u=["B","KB","MB","GB"],t=Math.floor(Math.log(r)/Math.log(f));return parseFloat((r/Math.pow(f,t)).toFixed(1))+" "+u[t]}function E(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function $(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function I(){try{let r=null,f=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,f=!0,w=e.stats)}catch{}if(!f){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const o of e.stats)r[o.name]={name:o.name,current:{cpu:o.cpu,memory:{percent:o.memory.percent,usage:o.memory.used,limit:o.memory.limit,usageMB:Math.round(o.memory.used/1048576),limitMB:Math.round(o.memory.limit/1048576)},network:{rxBytes:o.network.rx,txBytes:o.network.tx,rxMB:(o.network.rx/1048576).toFixed(1),txMB:(o.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:o.status};w=r}}if(!r||Object.keys(r).length===0){R.innerHTML='
No running containers found
';return}let u='
';for(const[t,e]of Object.entries(r)){const o=e.current||e,a=o.cpu?.percent||0,d=o.memory?.percent||0,n=E(a),i=$(d),c=o.memory?.usage||o.memory?.used||0,C=o.memory?.limit||0,B=o.network?.rxBytes||o.network?.rx||0,F=o.network?.txBytes||o.network?.tx||0,q=e.aggregated;u+=`
- ${e.name||n} + ${e.name||t} ${q?`avg ${q.cpu?.avg?.toFixed(0)||0}% cpu`:""} ${e.status||"running"}
@@ -1208,32 +1208,32 @@ This will replace current configuration, credentials, and data. Containers will
CPU
-
+
- ${a.toFixed(1)}% + ${a.toFixed(1)}%
Memory
-
+
- ${r.toFixed(1)}% + ${d.toFixed(1)}%
-
${M(l)} / ${M(C)}
+
${H(c)} / ${H(C)}
Network
- \u2193 ${M(I)} + \u2193 ${H(B)} / - \u2191 ${M(F)} + \u2191 ${H(F)}
- `}y+="",O.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(i){O.innerHTML=`
\u274C Failed to load stats: ${escapeHtml(i.message)}
`}}async function T(){if(!z)return;const i=b;if(!i||Object.keys(i).length===0){z.innerHTML='
\u{1F4C8}No monitoring data available. Open the Live Stats tab first.
';return}let $='
';for(const[y,n]of Object.entries(i)){const e=n.aggregated;e&&($+=`
-
${n.name||y}
+
`}u+="
",R.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){R.innerHTML=`
\u274C Failed to load stats: ${escapeHtml(r.message)}
`}}async function L(){if(!z)return;const r=w;if(!r||Object.keys(r).length===0){z.innerHTML='
\u{1F4C8}No monitoring data available. Open the Live Stats tab first.
';return}let f='
';for(const[u,t]of Object.entries(r)){const e=t.aggregated;e&&(f+=`
+
${t.name||u}
${e.cpu?.avg?.toFixed(1)||0}%Avg CPU
${e.cpu?.max?.toFixed(1)||0}%Max CPU
@@ -1241,27 +1241,27 @@ This will replace current configuration, credentials, and data. Containers will
${e.memory?.max?.toFixed(1)||0}%Max Mem
${e.dataPoints?`
${e.dataPoints} data points over ${e.timeRange||24}h
`:""} -
`)}$+="
",z.innerHTML=$}async function j(){if(!A)return;A.innerHTML='
Loading alerts...
';const i=b;if(!i||Object.keys(i).length===0){A.innerHTML='
\u{1F514}No containers found. Open the Live Stats tab first.
';return}let $=!1;try{$=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{$=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let n={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(n=s.configs||{})}catch{}const o=Object.entries(i).map(([t,s])=>{const l=n[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return` - - ${s.name||t} - - - - + `)}f+="",z.innerHTML=f}async function j(){if(!P)return;P.innerHTML='
Loading alerts...
';const r=w;if(!r||Object.keys(r).length===0){P.innerHTML='
\u{1F514}No containers found. Open the Live Stats tab first.
';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let u=[];try{const i=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();i.success&&(u=i.history||[])}catch{}let t={};try{const i=await(await fetch("/api/v1/monitoring/alerts/config")).json();i.success&&(t=i.configs||{})}catch{}const o=Object.entries(r).map(([n,i])=>{const c=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return` + + ${i.name||n} + + + + - + - `}).join(""),a=y.map(t=>{const s=new Date(t.timestamp).toLocaleString(),l=t.notified?"\u2713":"\u2014";return` + `}).join(""),a=u.map(n=>{const i=new Date(n.timestamp).toLocaleString(),c=n.notified?"\u2713":"\u2014";return` - ${s} - ${t.containerName||t.containerId} - ${t.metric||t.type} - ${typeof t.value=="number"?t.value.toFixed(1):t.value}${t.metric==="disk"?" MB/s":"%"} - ${l} - ${t.autoRestartTriggered?"\u21BB":""} + ${i} + ${n.containerName||n.containerId} + ${n.metric||n.type} + ${typeof n.value=="number"?n.value.toFixed(1):n.value}${n.metric==="disk"?" MB/s":"%"} + ${c} + ${n.autoRestartTriggered?"\u21BB":""} - `}).join(""),r=$?` + `}).join(""),d=f?`

\u2699\uFE0F Alert Configuration

@@ -1292,8 +1292,8 @@ This will replace current configuration, credentials, and data. Containers will

Upgrade to configure resource alert thresholds per container.

- `;A.innerHTML=` - ${r} + `;P.innerHTML=` + ${d}

\u{1F4CB} Recent Alerts

${a?` @@ -1314,21 +1314,21 @@ This will replace current configuration, credentials, and data. Containers will
`:'
No alerts recorded yet.
'}
- `,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;t[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),C=document.getElementById("save-all-alerts");C.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{C.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),h.classList.remove("show"),R(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=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=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),R(),typeof openLicenseModal=="function"&&openLicenseModal()})}function H(){L&&clearInterval(L),N?.checked&&(L=setInterval(S,DC.POLL.STATS))}function R(){L&&(clearInterval(L),L=null)}E?.addEventListener("click",()=>{h.classList.add("show"),S(),H()}),P?.addEventListener("click",()=>{h.classList.remove("show"),R()}),h?.addEventListener("click",i=>{i.target===h&&(h.classList.remove("show"),R())}),w?.addEventListener("click",S),N?.addEventListener("change",()=>{N.checked?H():R()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),g=document.querySelectorAll(".stats-range-btn");let u="1h";function f(i){switch(i){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 m(i){return i==="raw"?"live (10s samples)":i==="hourly"?"hourly average":i==="daily"?"daily average":i}function p(i,$,y,n,e){if(!i||i.length===0)return`
No data for ${escapeHtml(n)}
`;const o=i.map($).filter(G=>G!=null);if(o.length===0)return`
No data for ${escapeHtml(n)}
`;const a=Math.max(...o,1),r=Math.min(...o,0),t=a-r||1,s=600,l=80,C=4,I=(s-C*2)/Math.max(o.length-1,1),F=o.map((G,J)=>{const X=C+J*I,Q=l-C-(G-r)/t*(l-C*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=o[o.length-1],U=o.reduce((G,J)=>G+J,0)/o.length;return` + `,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;n[c]={cpuThreshold:parseInt(i.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(i.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(i.querySelector(".alert-disk")?.value)||50,autoRestart:!!i.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const c=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),C=document.getElementById("save-all-alerts");C.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{C.textContent="Save All"},2e3)}catch{const c=document.getElementById("save-all-alerts");c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),h.classList.remove("show"),O(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),O(),typeof openLicenseModal=="function"&&openLicenseModal()})}function M(){T&&clearInterval(T),N?.checked&&(T=setInterval(I,DC.POLL.STATS))}function O(){T&&(clearInterval(T),T=null)}S?.addEventListener("click",()=>{h.classList.add("show"),I(),M()}),A?.addEventListener("click",()=>{h.classList.remove("show"),O()}),h?.addEventListener("click",r=>{r.target===h&&(h.classList.remove("show"),O())}),k?.addEventListener("click",I),N?.addEventListener("change",()=>{N.checked?M():O()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",L),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),y=document.querySelectorAll(".stats-range-btn");let m="1h";function b(r){switch(r){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 p(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function v(r,f,u,t,e){if(!r||r.length===0)return`
No data for ${escapeHtml(t)}
`;const o=r.map(f).filter(G=>G!=null);if(o.length===0)return`
No data for ${escapeHtml(t)}
`;const a=Math.max(...o,1),d=Math.min(...o,0),n=a-d||1,i=600,c=80,C=4,B=(i-C*2)/Math.max(o.length-1,1),F=o.map((G,J)=>{const X=C+J*B,Q=c-C-(G-d)/n*(c-C*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=o[o.length-1],U=o.reduce((G,J)=>G+J,0)/o.length;return`
- ${escapeHtml(n)} + ${escapeHtml(t)} last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}
- - + +
- `}function d(){if(!x)return;const i=b||{},$=x.value,y=Object.entries(i);if(y.length===0){x.innerHTML='';return}x.innerHTML=y.map(([n,e])=>``).join(""),$&&i[$]&&(x.value=$)}async function c(){if(!D||!x)return;const i=x.value;if(!i){D.innerHTML='
\u{1F4CA}No container selected.
';return}const $=Date.now(),y=$-f(u);D.innerHTML='
Loading history...
';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(i)}?startTime=${y}&endTime=${$}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const o=e.samples||[],a=e.tier||"raw";if(o.length===0){D.innerHTML=`
\u{1F4CA}No data for the last ${u}. Tier: ${m(a)}.
`;return}const r=a==="raw",t=r?F=>F.cpu?.percent:F=>F.cpu?.avg,s=r?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=r?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,C=r?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let I=` + `}function s(){if(!x)return;const r=w||{},f=x.value,u=Object.entries(r);if(u.length===0){x.innerHTML='';return}x.innerHTML=u.map(([t,e])=>``).join(""),f&&r[f]&&(x.value=f)}async function l(){if(!D||!x)return;const r=x.value;if(!r){D.innerHTML='
\u{1F4CA}No container selected.
';return}const f=Date.now(),u=f-b(m);D.innerHTML='
Loading history...
';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(r)}?startTime=${u}&endTime=${f}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const o=e.samples||[],a=e.tier||"raw";if(o.length===0){D.innerHTML=`
\u{1F4CA}No data for the last ${m}. Tier: ${p(a)}.
`;return}const d=a==="raw",n=d?F=>F.cpu?.percent:F=>F.cpu?.avg,i=d?F=>F.memory?.percent:F=>F.memory?.avgPercent,c=d?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,C=d?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let B=`
- ${o.length} samples \xB7 ${escapeHtml(m(a))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date($).toLocaleString()} + ${o.length} samples \xB7 ${escapeHtml(p(a))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(f).toLocaleString()}
- `;I+=p(o,t,"#2ecc71","CPU","%"),I+=p(o,s,"#3498db","Memory","%"),I+=p(o,l,"#9b59b6","Network RX"," MB"),I+=p(o,C,"#e67e22","Network TX"," MB"),D.innerHTML=I}catch(n){D.innerHTML=`
\u26A0\uFE0FFailed to load history: ${escapeHtml(n.message)}
`}}g.forEach(i=>{i.addEventListener("click",()=>{g.forEach($=>$.classList.remove("active")),i.classList.add("active"),u=i.dataset.range,c()})}),x?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{d(),c()})})(),(function(){injectModal("health-modal",`
+ `;B+=v(o,n,"#2ecc71","CPU","%"),B+=v(o,i,"#3498db","Memory","%"),B+=v(o,c,"#9b59b6","Network RX"," MB"),B+=v(o,C,"#e67e22","Network TX"," MB"),D.innerHTML=B}catch(t){D.innerHTML=`
\u26A0\uFE0FFailed to load history: ${escapeHtml(t.message)}
`}}y.forEach(r=>{r.addEventListener("click",()=>{y.forEach(f=>f.classList.remove("active")),r.classList.add("active"),m=r.dataset.range,l()})}),x?.addEventListener("change",l),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),l()})})(),(function(){injectModal("health-modal",`

\u{1F3E5} Health Check Dashboard

-
`);const h=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),P=document.getElementById("health-cancel"),w=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),O=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),b=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save");let B=null;function S(g){return g>=99.9?"var(--ok-fg)":g>=95?"#f39c12":"var(--bad-fg)"}function T(g){const u={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`${g}`}async function j(){try{const u=await(await fetch("/api/v1/health-checks/status")).json();if(!u.success||!u.status||Object.keys(u.status).length===0){N.innerHTML='
\u{1F3E5}No health checks configured. Go to the Configure tab to add services.
';return}const f=Object.values(u.status);let m='';m+='',m+='',m+='',m+='';for(const p of f){const d=p.status==="up",c=d?"var(--dot-ok)":"var(--dot-bad)",i=p.uptime?.["24h"]??"-",$=p.uptime?.["7d"]??"-",y=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",n=p.timestamp?timeAgo(p.timestamp):"-";m+=``,m+=``,m+=``,m+=``,m+=``,m+=``,m+=``,m+="",m+=``}m+="
ServiceStatusUptime 24hUptime 7dAvg ResponseLast Check
${escapeHtml(p.name||p.serviceId)}${d?"Up":"Down"}${typeof i=="number"?i.toFixed(1)+"%":i}${typeof $=="number"?$.toFixed(1)+"%":$}${y}${n}
",N.innerHTML=m,A.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(p=>{p.addEventListener("click",async()=>{const d=p.dataset.healthId,c=document.getElementById("health-detail-"+d);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const $=await(await fetch(`/api/v1/health-checks/${d}/stats?hours=24`)).json();if($.success&&$.stats){const y=$.stats,n=y.responseTime||{};c.querySelector("td").innerHTML=` + `);const h=document.getElementById("health-modal"),S=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),k=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),P=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),E=document.getElementById("health-form-save");let $=null;function I(y){return y>=99.9?"var(--ok-fg)":y>=95?"#f39c12":"var(--bad-fg)"}function L(y){const m={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`${y}`}async function j(){try{const m=await(await fetch("/api/v1/health-checks/status")).json();if(!m.success||!m.status||Object.keys(m.status).length===0){N.innerHTML='
\u{1F3E5}No health checks configured. Go to the Configure tab to add services.
';return}const b=Object.values(m.status);let p='';p+='',p+='',p+='',p+='';for(const v of b){const s=v.status==="up",l=s?"var(--dot-ok)":"var(--dot-bad)",r=v.uptime?.["24h"]??"-",f=v.uptime?.["7d"]??"-",u=v.avgResponseTime!=null?Math.round(v.avgResponseTime)+"ms":"-",t=v.timestamp?timeAgo(v.timestamp):"-";p+=``,p+=``,p+=``,p+=``,p+=``,p+=``,p+=``,p+="",p+=``}p+="
ServiceStatusUptime 24hUptime 7dAvg ResponseLast Check
${escapeHtml(v.name||v.serviceId)}${s?"Up":"Down"}${typeof r=="number"?r.toFixed(1)+"%":r}${typeof f=="number"?f.toFixed(1)+"%":f}${u}${t}
",N.innerHTML=p,P.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(v=>{v.addEventListener("click",async()=>{const s=v.dataset.healthId,l=document.getElementById("health-detail-"+s);if(l){if(l.style.display!=="none"){l.style.display="none";return}l.style.display="";try{const f=await(await fetch(`/api/v1/health-checks/${s}/stats?hours=24`)).json();if(f.success&&f.stats){const u=f.stats,t=u.responseTime||{};l.querySelector("td").innerHTML=`
-
Total Checks
${y.totalChecks||0}
-
Uptime
${(y.uptime||0).toFixed(2)}%
-
Avg Response
${Math.round(n.avg||0)}ms
-
P95 / P99
${Math.round(n.p95||0)}ms / ${Math.round(n.p99||0)}ms
-
Min Response
${Math.round(n.min||0)}ms
-
Max Response
${Math.round(n.max||0)}ms
-
Up Checks
${y.upChecks||0}
-
Down Checks
${y.downChecks||0}
-
`}else c.querySelector("td").innerHTML='
No detailed stats available for this period.
'}catch(i){c.querySelector("td").innerHTML=`
Failed: ${escapeHtml(i.message)}
`}}})})}catch(g){N.innerHTML=`
Failed to load health status: ${escapeHtml(g.message)}
`}}async function H(){try{const[g,u]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),f=await g.json(),m=await u.json();let p="";const d=f.success&&f.incidents?f.incidents:[];if(d.length>0){p+='

Open Incidents ('+d.length+")

";for(const i of d)p+=`
+
Total Checks
${u.totalChecks||0}
+
Uptime
${(u.uptime||0).toFixed(2)}%
+
Avg Response
${Math.round(t.avg||0)}ms
+
P95 / P99
${Math.round(t.p95||0)}ms / ${Math.round(t.p99||0)}ms
+
Min Response
${Math.round(t.min||0)}ms
+
Max Response
${Math.round(t.max||0)}ms
+
Up Checks
${u.upChecks||0}
+
Down Checks
${u.downChecks||0}
+
`}else l.querySelector("td").innerHTML='
No detailed stats available for this period.
'}catch(r){l.querySelector("td").innerHTML=`
Failed: ${escapeHtml(r.message)}
`}}})})}catch(y){N.innerHTML=`
Failed to load health status: ${escapeHtml(y.message)}
`}}async function M(){try{const[y,m]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),b=await y.json(),p=await m.json();let v="";const s=b.success&&b.incidents?b.incidents:[];if(s.length>0){v+='

Open Incidents ('+s.length+")

";for(const r of s)v+=`
- ${escapeHtml(i.serviceId)} - ${T(i.severity)} + ${escapeHtml(r.serviceId)} + ${L(r.severity)}
-
${escapeHtml(i.message)}
-
Started ${timeAgo(i.createdAt)} \xB7 ${i.occurrences||1} occurrence(s)
-
`;p+="
"}else p+='
All services operational \u2014 no open incidents
';const c=m.success&&m.history?m.history:[];if(c.length>0){p+='

Incident History

',p+='',p+='';for(const i of c){const $=i.status==="resolved",y=$&&i.duration?i.duration<6e4?Math.round(i.duration/1e3)+"s":Math.round(i.duration/6e4)+"m":"-";p+='',p+=``,p+=``,p+=``,p+=``,p+=``,p+=``,p+=""}p+="
ServiceTypeSeverityStatusDurationWhen
${escapeHtml(i.serviceId)}${escapeHtml(i.type)}${T(i.severity)}${i.status}${y}${timeAgo(i.createdAt)}
"}O.innerHTML=p||'
\u{1F6A8}No incidents recorded yet.
'}catch(g){O.innerHTML=`
Failed: ${escapeHtml(g.message)}
`}}async function R(){try{const u=await(await fetch("/api/v1/health-checks/status")).json(),f=u.success&&u.status?Object.values(u.status):[];if(f.length===0){z.innerHTML='
\u2699\uFE0FNo health checks configured yet. Click "Add Health Check" below.
';return}let m='';m+='';for(const p of f){const d=p.status==="up";m+='',m+=``,m+=``,m+=``,m+='"}m+="
ServiceStatusSLA TargetActions
${escapeHtml(p.name||p.serviceId)}${d?"Up":"Down"}${p.sla?.target?p.sla.target+"%":"-"}',m+=``,m+=``,m+="
",z.innerHTML=m}catch(g){z.innerHTML=`
Failed: ${escapeHtml(g.message)}
`}}function x(g,u,f,m,p,d,c){B=g||null,b.textContent=g?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=g||"",document.getElementById("health-form-id").disabled=!!g,document.getElementById("health-form-name").value=u||"",document.getElementById("health-form-url").value=f||"",document.getElementById("health-form-timeout").value=m||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=d||99.9,document.getElementById("health-form-slow").value=c||5e3,L.style.display="",v.style.display="none"}function D(){L.style.display="none",v.style.display="",B=null}v?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",D),k?.addEventListener("click",async()=>{const g=B||document.getElementById("health-form-id").value.trim();if(!g)return showNotification("Service ID is required","warning");const u=document.getElementById("health-form-url").value.trim();if(!u)return showNotification("URL is required","warning");const f=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),m={name:document.getElementById("health-form-name").value.trim()||g,url:u,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:f.length?f:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)||5e3};try{k.textContent="Saving...",k.disabled=!0;const d=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(g)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)})).json();if(!d.success)throw new Error(d.error||"Save failed");D(),R(),j()}catch(p){showNotification("Error: "+p.message,"error")}finally{k.textContent="Save",k.disabled=!1}}),document.addEventListener("health-edit",async g=>{const u=g.detail;x(u,"","",1e4,"200",99.9,5e3)}),document.addEventListener("health-delete",async g=>{const u=g.detail;if(confirm(`Delete health check for "${u}"?`))try{const m=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(u)}/configure`,{method:"DELETE"})).json();if(!m.success)throw new Error(m.error);R(),j()}catch(f){showNotification("Error: "+f.message,"error")}}),E?.addEventListener("click",()=>{h?.classList.add("show"),j()}),wireModal(h,P),w?.addEventListener("click",j),document.querySelector('[data-panel="health-incidents"]')?.addEventListener("click",H),document.querySelector('[data-panel="health-config"]')?.addEventListener("click",R)})(),(function(){injectModal("updates-modal",`
+
${escapeHtml(r.message)}
+
Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)
+
`;v+="
"}else v+='
All services operational \u2014 no open incidents
';const l=p.success&&p.history?p.history:[];if(l.length>0){v+='

Incident History

',v+='',v+='';for(const r of l){const f=r.status==="resolved",u=f&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";v+='',v+=``,v+=``,v+=``,v+=``,v+=``,v+=``,v+=""}v+="
ServiceTypeSeverityStatusDurationWhen
${escapeHtml(r.serviceId)}${escapeHtml(r.type)}${L(r.severity)}${r.status}${u}${timeAgo(r.createdAt)}
"}R.innerHTML=v||'
\u{1F6A8}No incidents recorded yet.
'}catch(y){R.innerHTML=`
Failed: ${escapeHtml(y.message)}
`}}async function O(){try{const m=await(await fetch("/api/v1/health-checks/status")).json(),b=m.success&&m.status?Object.values(m.status):[];if(b.length===0){z.innerHTML='
\u2699\uFE0FNo health checks configured yet. Click "Add Health Check" below.
';return}let p='';p+='';for(const v of b){const s=v.status==="up";p+='',p+=``,p+=``,p+=``,p+='"}p+="
ServiceStatusSLA TargetActions
${escapeHtml(v.name||v.serviceId)}${s?"Up":"Down"}${v.sla?.target?v.sla.target+"%":"-"}',p+=``,p+=``,p+="
",z.innerHTML=p}catch(y){z.innerHTML=`
Failed: ${escapeHtml(y.message)}
`}}function x(y,m,b,p,v,s,l){$=y||null,w.textContent=y?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=y||"",document.getElementById("health-form-id").disabled=!!y,document.getElementById("health-form-name").value=m||"",document.getElementById("health-form-url").value=b||"",document.getElementById("health-form-timeout").value=p||1e4,document.getElementById("health-form-codes").value=v||"200",document.getElementById("health-form-sla").value=s||99.9,document.getElementById("health-form-slow").value=l||5e3,T.style.display="",g.style.display="none"}function D(){T.style.display="none",g.style.display="",$=null}g?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",D),E?.addEventListener("click",async()=>{const y=$||document.getElementById("health-form-id").value.trim();if(!y)return showNotification("Service ID is required","warning");const m=document.getElementById("health-form-url").value.trim();if(!m)return showNotification("URL is required","warning");const b=document.getElementById("health-form-codes").value.split(",").map(v=>parseInt(v.trim())).filter(Boolean),p={name:document.getElementById("health-form-name").value.trim()||y,url:m,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:b.length?b:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)||5e3};try{E.textContent="Saving...",E.disabled=!0;const s=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(y)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(p)})).json();if(!s.success)throw new Error(s.error||"Save failed");D(),O(),j()}catch(v){showNotification("Error: "+v.message,"error")}finally{E.textContent="Save",E.disabled=!1}}),document.addEventListener("health-edit",async y=>{const m=y.detail;x(m,"","",1e4,"200",99.9,5e3)}),document.addEventListener("health-delete",async y=>{const m=y.detail;if(confirm(`Delete health check for "${m}"?`))try{const p=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(m)}/configure`,{method:"DELETE"})).json();if(!p.success)throw new Error(p.error);O(),j()}catch(b){showNotification("Error: "+b.message,"error")}}),S?.addEventListener("click",()=>{h?.classList.add("show"),j()}),wireModal(h,A),k?.addEventListener("click",j),document.querySelector('[data-panel="health-incidents"]')?.addEventListener("click",M),document.querySelector('[data-panel="health-config"]')?.addEventListener("click",O)})(),(function(){injectModal("updates-modal",`

\u2B06\uFE0F Update Management

- `);const h=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),P=document.getElementById("updates-cancel"),w=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),O=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function v(){try{const n=await(await fetch("/api/v1/updates/available")).json();if(!n.success)throw new Error(n.error);const e=n.updates||[];if(e.length===0){N.innerHTML='
\u2705All containers are up to date.
',A.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let o='';o+='';for(const t of e){const s=(()=>{const l=window.APPS||[];for(const C of l)if(C.containerId===t.containerId||C.name===t.containerName||C.id===t.containerName)return C.id;return t.containerName})();o+=``,o+=``,o+=``,o+=``,o+=``,o+='"}o+="
ContainerImageCurrentLatestActions
${escapeHtml(t.containerName)}${escapeHtml(t.imageName)}${escapeHtml(t.currentDigest)}${escapeHtml(t.latestDigest)}',o+=``,o+=``,o+="
",N.innerHTML=o,A.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),r=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),r&&e.length>0&&(r.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.dataset.id,l=t.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(I.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Update failed")}catch(C){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+C.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 s=t.dataset.id,l=t.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(I.success)t.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Rollback failed")}catch(C){t.textContent="Failed",showNotification("Rollback error: "+C.message,"error"),setTimeout(()=>{t.textContent="Rollback",t.disabled=!1},3e3)}}})})}catch(y){N.innerHTML=`
Failed: ${escapeHtml(y.message)}
`}}async function L(){const y=window._pendingUpdates||[];if(!y.length)return;const n=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;n.textContent="\u23F3 Updating...",n.disabled=!0;let e=0,o=0;for(const a of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:o++}catch{o++}n.textContent="\u2705 Done",showNotification(`Update all: ${e} succeeded, ${o} failed.`,e>0&&o===0?"success":"error"),setTimeout(()=>{n.textContent="\u2B06\uFE0F Update All",n.disabled=!1,v()},3e3)}document.getElementById("updates-update-all-btn")?.addEventListener("click",L);async function b(){w.textContent="\u{1F50D} Checking...",w.disabled=!0;try{const n=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!n.success)throw new Error(n.error);w.textContent="\u2705 Done!",await v()}catch(y){w.textContent="\u274C Failed",showNotification("Check error: "+y.message,"error")}setTimeout(()=>{w.textContent="\u{1F50D} Check for Updates",w.disabled=!1},3e3)}async function M(){try{O.innerHTML='
Loading...
';const n=await(await fetch("/api/v1/updates/history?limit=50")).json(),e=n.success&&n.history?n.history:[];if(e.length===0){O.innerHTML='
\u{1F4CB}No update history yet.
';return}let o='';o+='';for(const a of e){const r=a.status==="success",t=a.duration?a.duration<1e3?a.duration+"ms":Math.round(a.duration/1e3)+"s":"-";o+='',o+=``,o+=``,o+=``,o+=``,o+=``,o+="",!r&&a.error&&(o+=``)}o+="
WhenContainerImageDurationStatus
${timeAgo(a.timestamp)}${escapeHtml(a.containerName)}${escapeHtml(a.imageName)}${t}${r?"\u2713 success":"\u2717 failed"}
${escapeHtml(a.error)}
",O.innerHTML=o}catch(y){O.innerHTML=`
Failed: ${escapeHtml(y.message)}
`}}async function k(){try{z.innerHTML='
Loading...
';const[y,n]=await Promise.all([fetch("/api/v1/stats/containers"),fetch("/api/v1/updates/auto-update")]),e=await y.json(),o=await n.json(),a=e.success&&e.stats?e.stats:[],r=o.success&&o.config?o.config:{};if(a.length===0){z.innerHTML='
\u{1F916}No running containers found.
';return}let t='
Auto-updates run during maintenance window (default 2AM-4AM). Daily = every day, Weekly = Sundays, Monthly = 1st of month.
';t+='',t+='';for(const s of a){const l=s.name||s.Names?.[0]?.replace(/^\//,"")||s.Id?.substring(0,12),C=s.containerId||s.Id,I=r[C]||{},F=I.enabled?I.schedule||"weekly":"",q=I.autoRollback!==!1,U=I.maintenanceWindow||"",G=I.lastAutoUpdate?timeAgo(I.lastAutoUpdate):"Never";t+=``,t+=``,t+=``,n+=``,n+=``,n+=``,n+=``,n+=""}n+="
ContainerScheduleWindowRollbackLast RunActions
${escapeHtml(l)} + `);const h=document.getElementById("updates-modal"),S=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),k=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),P=document.getElementById("updates-last-check");async function g(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){N.innerHTML='
\u2705All containers are up to date.
',P.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let o='';o+='';for(const n of e){const i=(()=>{const c=window.APPS||[];for(const C of c)if(C.containerId===n.containerId||C.name===n.containerName||C.id===n.containerName)return C.id;return n.containerName})();o+=``,o+=``,o+=``,o+=``,o+=``,o+='"}o+="
ContainerImageCurrentLatestActions
${escapeHtml(n.containerName)}${escapeHtml(n.imageName)}${escapeHtml(n.currentDigest)}${escapeHtml(n.latestDigest)}',o+=``,o+=``,o+="
",N.innerHTML=o,P.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),d=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),d&&e.length>0&&(d.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.dataset.id,c=n.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const B=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(B.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(B.error||"Update failed")}catch(C){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+C.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.dataset.id,c=n.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const B=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(B.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(B.error||"Rollback failed")}catch(C){n.textContent="Failed",showNotification("Rollback error: "+C.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(u){N.innerHTML=`
Failed: ${escapeHtml(u.message)}
`}}async function T(){const u=window._pendingUpdates||[];if(!u.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${u.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,o=0;for(const a of u)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:o++}catch{o++}t.textContent="\u2705 Done",showNotification(`Update all: ${e} succeeded, ${o} failed.`,e>0&&o===0?"success":"error"),setTimeout(()=>{t.textContent="\u2B06\uFE0F Update All",t.disabled=!1,g()},3e3)}document.getElementById("updates-update-all-btn")?.addEventListener("click",T);async function w(){k.textContent="\u{1F50D} Checking...",k.disabled=!0;try{const t=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!t.success)throw new Error(t.error);k.textContent="\u2705 Done!",await g()}catch(u){k.textContent="\u274C Failed",showNotification("Check error: "+u.message,"error")}setTimeout(()=>{k.textContent="\u{1F50D} Check for Updates",k.disabled=!1},3e3)}async function H(){try{R.innerHTML='
Loading...
';const t=await(await fetch("/api/v1/updates/history?limit=50")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){R.innerHTML='
\u{1F4CB}No update history yet.
';return}let o='';o+='';for(const a of e){const d=a.status==="success",n=a.duration?a.duration<1e3?a.duration+"ms":Math.round(a.duration/1e3)+"s":"-";o+='',o+=``,o+=``,o+=``,o+=``,o+=``,o+="",!d&&a.error&&(o+=``)}o+="
WhenContainerImageDurationStatus
${timeAgo(a.timestamp)}${escapeHtml(a.containerName)}${escapeHtml(a.imageName)}${n}${d?"\u2713 success":"\u2717 failed"}
${escapeHtml(a.error)}
",R.innerHTML=o}catch(u){R.innerHTML=`
Failed: ${escapeHtml(u.message)}
`}}async function E(){try{z.innerHTML='
Loading...
';const[u,t]=await Promise.all([fetch("/api/v1/stats/containers"),fetch("/api/v1/updates/auto-update")]),e=await u.json(),o=await t.json(),a=e.success&&e.stats?e.stats:[],d=o.success&&o.config?o.config:{};if(a.length===0){z.innerHTML='
\u{1F916}No running containers found.
';return}let n='
Auto-updates run during maintenance window (default 2AM-4AM). Daily = every day, Weekly = Sundays, Monthly = 1st of month.
';n+='',n+='';for(const i of a){const c=i.name||i.Names?.[0]?.replace(/^\//,"")||i.Id?.substring(0,12),C=i.containerId||i.Id,B=d[C]||{},F=B.enabled?B.schedule||"weekly":"",q=B.autoRollback!==!1,U=B.maintenanceWindow||"",G=B.lastAutoUpdate?timeAgo(B.lastAutoUpdate):"Never";n+=``,n+=``,n+=``,t+=``,t+=``,t+=``,t+=``,t+=""}t+="
ContainerScheduleWindowRollbackLast RunActions
${escapeHtml(c)} ${G}
",z.innerHTML=t,z.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,C=s.closest("tr"),I=C.querySelector(".auto-schedule").value,F=C.querySelector(".auto-rollback").checked,q=C.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!I,schedule:I||"weekly",autoRollback:F,maintenanceWindow:q||void 0})})).json();if(G.success)s.textContent="\u2713 Saved";else throw new Error(G.error)}catch(U){s.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){z.innerHTML=`
Failed: ${escapeHtml(y.message)}
`}}const B=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),H=document.getElementById("dashcaddy-changelog"),R=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),g=document.getElementById("dashcaddy-status-bar"),u=document.getElementById("dashcaddy-history-container");let f=null;function m(y,n){g&&(g.style.display="block",g.style.background=n==="error"?"var(--bad-bg)":n==="success"?"var(--ok-bg)":"var(--bg)",g.style.color=n==="error"?"var(--bad-fg)":n==="success"?"var(--ok-fg)":"var(--fg)",g.textContent=y)}async function p(){try{const n=await(await fetch("/api/v1/system/version")).json();if(n.success){const e=n.commit&&n.commit!=="unknown"?n.commit:null;B.textContent="v"+n.version+(e?" ("+e.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function d(y){y||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(f=e,e.success&&e.available&&e.remote){S.style.display="",T.style.display="",j.textContent="v"+e.remote.version,H.textContent=e.remote.changelog||"No changelog available.";const o=document.getElementById("updates-btn");if(o&&!o.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",o.style.position="relative",o.appendChild(r)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(r)}}else S.style.display="none",T.style.display="none",await p(),y||m("You are running the latest version.","success");y||(x.textContent="Check for Updates",x.disabled=!1)}catch(n){y||(m("Failed to check: "+n.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;R.textContent="Updating...",R.disabled=!0,m("Downloading and applying update...","info");try{const n=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(n.success)return m("Update initiated: v"+(n.fromVersion||"?")+" \u2192 v"+(n.toVersion||"?")+". The container will restart shortly.","success"),R.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(n.error||"Update failed")}catch(y){throw m("Update failed: "+y.message,"error"),R.textContent="Update Now",R.disabled=!1,y}}async function i(){try{const n=await(await fetch("/api/v1/system/update-history")).json(),e=n.success&&n.history?n.history:[];if(e.length===0){u.innerHTML='
\u{1F4E6}No self-update history.
';return}let o='';o+='';for(const a of e){const r=a.status==="success"?"\u2713 success":a.status==="pending"?"\u23F3 pending":a.status==="partial"?"\u26A0 partial":"\u2717 "+a.status,t=a.status==="success"?"var(--ok-fg)":a.status==="pending"?"var(--muted)":"var(--bad-fg)";o+='',o+='",o+='",o+='",o+='",o+="",a.error&&(o+='"),a.note&&(o+='")}o+="
WhenVersionFromStatus
'+timeAgo(a.timestamp)+"v'+escapeHtml(a.version)+(a.rollback?" (rollback)":"")+"v'+escapeHtml(a.fromVersion||"?")+"'+r+"
'+escapeHtml(a.error)+"
'+escapeHtml(a.note)+"
",u.innerHTML=o}catch(y){u.innerHTML='
Failed: '+escapeHtml(y.message)+"
"}}async function $(){try{const n=await(await fetch("/api/v1/system/rollback-versions")).json(),e=n.success&&n.versions?n.versions:[];if(e.length===0){showNotification("No rollback versions available.","info");return}const o=prompt(`Available rollback versions: +
${G}
",z.innerHTML=n,z.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,C=i.closest("tr"),B=C.querySelector(".auto-schedule").value,F=C.querySelector(".auto-rollback").checked,q=C.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!B,schedule:B||"weekly",autoRollback:F,maintenanceWindow:q||void 0})})).json();if(G.success)i.textContent="\u2713 Saved";else throw new Error(G.error)}catch(U){i.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(u){z.innerHTML=`
Failed: ${escapeHtml(u.message)}
`}}const $=document.getElementById("dashcaddy-current-version"),I=document.getElementById("dashcaddy-update-badge"),L=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),M=document.getElementById("dashcaddy-changelog"),O=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),y=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let b=null;function p(u,t){y&&(y.style.display="block",y.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",y.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",y.textContent=u)}async function v(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(u){u||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(b=e,e.success&&e.available&&e.remote){I.style.display="",L.style.display="",j.textContent="v"+e.remote.version,M.textContent=e.remote.changelog||"No changelog available.";const o=document.getElementById("updates-btn");if(o&&!o.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",o.style.position="relative",o.appendChild(d)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(d)}}else I.style.display="none",L.style.display="none",await v(),u||p("You are running the latest version.","success");u||(x.textContent="Check for Updates",x.disabled=!1)}catch(t){u||(p("Failed to check: "+t.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function l(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;O.textContent="Updating...",O.disabled=!0,p("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return p("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),O.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(u){throw p("Update failed: "+u.message,"error"),O.textContent="Update Now",O.disabled=!1,u}}async function r(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='
\u{1F4E6}No self-update history.
';return}let o='';o+='';for(const a of e){const d=a.status==="success"?"\u2713 success":a.status==="pending"?"\u23F3 pending":a.status==="partial"?"\u26A0 partial":"\u2717 "+a.status,n=a.status==="success"?"var(--ok-fg)":a.status==="pending"?"var(--muted)":"var(--bad-fg)";o+='',o+='",o+='",o+='",o+='",o+="",a.error&&(o+='"),a.note&&(o+='")}o+="
WhenVersionFromStatus
'+timeAgo(a.timestamp)+"v'+escapeHtml(a.version)+(a.rollback?" (rollback)":"")+"v'+escapeHtml(a.fromVersion||"?")+"'+d+"
'+escapeHtml(a.error)+"
'+escapeHtml(a.note)+"
",m.innerHTML=o}catch(u){m.innerHTML='
Failed: '+escapeHtml(u.message)+"
"}}async function f(){try{const t=await(await fetch("/api/v1/system/rollback-versions")).json(),e=t.success&&t.versions?t.versions:[];if(e.length===0){showNotification("No rollback versions available.","info");return}const o=prompt(`Available rollback versions: `+e.join(` `)+` -Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification("Invalid version: "+o,"error");return}if(!confirm("Rollback DashCaddy to v"+o+"? The container will restart."))return;m("Rolling back to v"+o+"...","info");const r=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:o})})).json();if(r.success)m("Rollback to v"+o+" initiated. Container will restart.","success");else throw new Error(r.error||"Rollback failed")}catch(y){m("Rollback failed: "+y.message,"error")}}x?.addEventListener("click",()=>d(!1)),R?.addEventListener("click",()=>c().catch(()=>{})),D?.addEventListener("click",$),w?.addEventListener("click",b),E?.addEventListener("click",()=>{h?.classList.add("show"),v()}),wireModal(h,P),window.openUpdateModal=function(y){h?.classList.add("show"),v().then(()=>{if(!y)return;const n=N.querySelector(`[data-app-id="${y}"]`);n&&(n.scrollIntoView({behavior:"smooth",block:"center"}),n.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{n.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",M),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{p(),i(),f||d(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=d,setTimeout(()=>d(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`
+Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification("Invalid version: "+o,"error");return}if(!confirm("Rollback DashCaddy to v"+o+"? The container will restart."))return;p("Rolling back to v"+o+"...","info");const d=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:o})})).json();if(d.success)p("Rollback to v"+o+" initiated. Container will restart.","success");else throw new Error(d.error||"Rollback failed")}catch(u){p("Rollback failed: "+u.message,"error")}}x?.addEventListener("click",()=>s(!1)),O?.addEventListener("click",()=>l().catch(()=>{})),D?.addEventListener("click",f),k?.addEventListener("click",w),S?.addEventListener("click",()=>{h?.classList.add("show"),g()}),wireModal(h,A),window.openUpdateModal=function(u){h?.classList.add("show"),g().then(()=>{if(!u)return;const t=N.querySelector(`[data-app-id="${u}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",E),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),r(),b||s(!0)}),window.dcApplyUpdate=l,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`

\u{1F433} Docker Resources

@@ -1564,7 +1564,7 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
-
`);const h=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),P=document.getElementById("dr-close");function w(A){if(!A||A===0)return"0 B";const v=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(A))/Math.log(1024));return(A/Math.pow(1024,L)).toFixed(1)+" "+v[L]}async function N(){const A=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){A.innerHTML='
\u{1F4E6}No volumes found.
';return}let b='';b+='';for(const M of L){const k=M.name==="buildkit"||M.name.length===64;b+='',b+=``,b+=``,b+=``,b+='"}b+="
NameDriverScopeActions
${escapeHtml(M.name.length>40?M.name.substring(0,37)+"...":M.name)}${escapeHtml(M.driver)}${escapeHtml(M.scope)}',k||(b+=``),b+="
",A.innerHTML=b,A.querySelectorAll(".dr-vol-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete volume "${M.dataset.name}"? Data will be lost.`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(M.dataset.name)}?force=true`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`
Failed: ${escapeHtml(v.message)}
`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-vol-name"),v=A.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),A.value="",showNotification(`Volume "${v}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function O(){const A=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){A.innerHTML='
\u{1F310}No networks found.
';return}let b='';b+='';for(const M of L){const k=["bridge","host","none"].includes(M.name);b+='',b+=``,b+=``,b+=``,b+=``,b+='"}b+="
NameDriverScopeContainersActions
${escapeHtml(M.name)}${escapeHtml(M.driver)}${escapeHtml(M.scope)}${M.containers}',k||(b+=``),b+="
",A.innerHTML=b,A.querySelectorAll(".dr-net-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete network "${M.dataset.name}"?`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(M.dataset.id)}`),O()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`
Failed: ${escapeHtml(v.message)}
`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),L=A.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:v.value}),A.value="",showNotification(`Network "${L}" created`,"success"),O()}catch(b){showNotification("Create failed: "+b.message,"error")}});async function z(){const A=document.getElementById("dr-disk-content");try{const v=await getJSON("/api/v1/docker/disk-usage"),L=[{label:"Images",icon:"\u{1F4C0}",count:v.images.count,size:v.images.size,reclaimable:v.images.reclaimable},{label:"Containers",icon:"\u{1F4E6}",count:v.containers.count,size:v.containers.size,extra:`${v.containers.running} running`},{label:"Volumes",icon:"\u{1F4BE}",count:v.volumes.count,size:v.volumes.size,reclaimable:v.volumes.reclaimable},{label:"Build Cache",icon:"\u{1F527}",count:v.buildCache.count,size:v.buildCache.size,reclaimable:v.buildCache.reclaimable}];let b=`
Total: ${w(v.totalSize)}
`;b+='
';for(const M of L)b+='
',b+=`
${M.icon} ${M.label} (${M.count})
`,b+=`
${w(M.size)}
`,M.reclaimable>0&&(b+=`
Reclaimable: ${w(M.reclaimable)}
`),M.extra&&(b+=`
${M.extra}
`),b+="
";b+="
",A.innerHTML=b}catch(v){A.innerHTML=`
Failed: ${escapeHtml(v.message)}
`}}E?.addEventListener("click",()=>{h?.classList.add("show"),N()}),wireModal(h,P),document.querySelector('[data-panel="dr-networks"]')?.addEventListener("click",O),document.querySelector('[data-panel="dr-disk"]')?.addEventListener("click",z)})(),(function(){injectModal("compose-import-modal",`
+
`);const h=document.getElementById("docker-resources-modal"),S=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function k(P){if(!P||P===0)return"0 B";const g=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(P))/Math.log(1024));return(P/Math.pow(1024,T)).toFixed(1)+" "+g[T]}async function N(){const P=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){P.innerHTML='
\u{1F4E6}No volumes found.
';return}let w='';w+='';for(const H of T){const E=H.name==="buildkit"||H.name.length===64;w+='',w+=``,w+=``,w+=``,w+='"}w+="
NameDriverScopeActions
${escapeHtml(H.name.length>40?H.name.substring(0,37)+"...":H.name)}${escapeHtml(H.driver)}${escapeHtml(H.scope)}',E||(w+=``),w+="
",P.innerHTML=w,P.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(E){showNotification("Delete failed: "+E.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(g){P.innerHTML=`
Failed: ${escapeHtml(g.message)}
`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-vol-name"),g=P.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),P.value="",showNotification(`Volume "${g}" created`,"success"),N()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function R(){const P=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){P.innerHTML='
\u{1F310}No networks found.
';return}let w='';w+='';for(const H of T){const E=["bridge","host","none"].includes(H.name);w+='',w+=``,w+=``,w+=``,w+=``,w+='"}w+="
NameDriverScopeContainersActions
${escapeHtml(H.name)}${escapeHtml(H.driver)}${escapeHtml(H.scope)}${H.containers}',E||(w+=``),w+="
",P.innerHTML=w,P.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)}`),R()}catch(E){showNotification("Delete failed: "+E.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(g){P.innerHTML=`
Failed: ${escapeHtml(g.message)}
`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),T=P.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:g.value}),P.value="",showNotification(`Network "${T}" created`,"success"),R()}catch(w){showNotification("Create failed: "+w.message,"error")}});async function z(){const P=document.getElementById("dr-disk-content");try{const g=await getJSON("/api/v1/docker/disk-usage"),T=[{label:"Images",icon:"\u{1F4C0}",count:g.images.count,size:g.images.size,reclaimable:g.images.reclaimable},{label:"Containers",icon:"\u{1F4E6}",count:g.containers.count,size:g.containers.size,extra:`${g.containers.running} running`},{label:"Volumes",icon:"\u{1F4BE}",count:g.volumes.count,size:g.volumes.size,reclaimable:g.volumes.reclaimable},{label:"Build Cache",icon:"\u{1F527}",count:g.buildCache.count,size:g.buildCache.size,reclaimable:g.buildCache.reclaimable}];let w=`
Total: ${k(g.totalSize)}
`;w+='
';for(const H of T)w+='
',w+=`
${H.icon} ${H.label} (${H.count})
`,w+=`
${k(H.size)}
`,H.reclaimable>0&&(w+=`
Reclaimable: ${k(H.reclaimable)}
`),H.extra&&(w+=`
${H.extra}
`),w+="
";w+="
",P.innerHTML=w}catch(g){P.innerHTML=`
Failed: ${escapeHtml(g.message)}
`}}S?.addEventListener("click",()=>{h?.classList.add("show"),N()}),wireModal(h,A),document.querySelector('[data-panel="dr-networks"]')?.addEventListener("click",R),document.querySelector('[data-panel="dr-disk"]')?.addEventListener("click",z)})(),(function(){injectModal("compose-import-modal",`

\u{1F4E6} Import Docker Compose

@@ -1605,7 +1605,7 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
- `);const h=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),P=document.getElementById("compose-cancel");wireModal(h,P);let w=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}E?.addEventListener("click",()=>{N("paste"),w=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const A=z.target.files[0];if(!A)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(A)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),A=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),L=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const b=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:A});w=b,w.stackName=A,O(b),N("preview")}catch(b){showNotification("Parse failed: "+b.message,"error")}finally{v.textContent=L,v.disabled=!1}});function O(z){const A=document.getElementById("compose-preview-content");let v="";z.networks&&z.networks.length>0&&(v+=`
Networks: ${z.networks.map(L=>`${escapeHtml(L)}`).join(", ")}
`),z.volumes&&z.volumes.length>0&&(v+=`
Volumes: ${z.volumes.map(L=>`${escapeHtml(L)}`).join(", ")}
`),v+=`
${z.services.length} service(s)
`,v+='
';for(const L of z.services){const b=L.skip?"var(--bad-fg)":"var(--border)";if(v+=`
`,v+=`
${escapeHtml(L.name)}`,L.skip&&(v+=` \u2014 skipped: ${escapeHtml(L.reason)}`),v+="
",!L.skip&&(v+=`
Image: ${escapeHtml(L.image)}
`,L.ports?.length&&(v+=`
Ports: ${L.ports.map(M=>`${M.host}:${M.container}`).join(", ")}
`),L.volumes?.length&&(v+=`
Volumes: ${L.volumes.length}
`),Object.keys(L.environment||{}).length&&(v+=`
Env vars: ${Object.keys(L.environment).length}
`),L.envFileWarning&&(v+=`
\u26A0 ${escapeHtml(L.envFileWarning)}
`),L.resources?.cpus||L.resources?.memory)){const M=[];L.resources.cpus&&M.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&M.push(`Mem: ${L.resources.memory}MB`),v+=`
Limits: ${M.join(", ")}
`}v+="
"}v+="
",A.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!w)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const A=document.getElementById("compose-progress-content");A.innerHTML='
Deploying services...
';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:w.services,networks:w.networks,stackName:w.stackName});let L=`
Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete
`;L+='
';for(const b of v.results){const M=b.status==="deployed"||b.status==="created"?"\u2705":b.status==="exists"?"\u26A1":b.status==="skipped"?"\u23ED":"\u274C";L+='
',L+=`${M} ${escapeHtml(b.name)} (${b.type}) \u2014 ${escapeHtml(b.status)}`,b.error&&(L+=` ${escapeHtml(b.error)}`),b.subdomain&&(L+=` \u2192 ${escapeHtml(b.subdomain)}`),b.reason&&(L+=` (${escapeHtml(b.reason)})`),L+="
"}L+="
",L+='',A.innerHTML=L,document.getElementById("compose-done-btn")?.addEventListener("click",()=>{h?.classList.remove("show"),typeof window.loadServices=="function"&&window.loadServices().then(()=>{typeof window.buildGrid=="function"&&window.buildGrid()})}),showNotification(`Stack "${v.stackName}" deployed`,"success")}catch(v){A.innerHTML=`
Deployment failed: ${escapeHtml(v.message)}
+ `);const h=document.getElementById("compose-import-modal"),S=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(h,A);let k=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}S?.addEventListener("click",()=>{N("paste"),k=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const P=z.target.files[0];if(!P)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(P)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),P=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),T=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:P});k=w,k.stackName=P,R(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{g.textContent=T,g.disabled=!1}});function R(z){const P=document.getElementById("compose-preview-content");let g="";z.networks&&z.networks.length>0&&(g+=`
Networks: ${z.networks.map(T=>`${escapeHtml(T)}`).join(", ")}
`),z.volumes&&z.volumes.length>0&&(g+=`
Volumes: ${z.volumes.map(T=>`${escapeHtml(T)}`).join(", ")}
`),g+=`
${z.services.length} service(s)
`,g+='
';for(const T of z.services){const w=T.skip?"var(--bad-fg)":"var(--border)";if(g+=`
`,g+=`
${escapeHtml(T.name)}`,T.skip&&(g+=` \u2014 skipped: ${escapeHtml(T.reason)}`),g+="
",!T.skip&&(g+=`
Image: ${escapeHtml(T.image)}
`,T.ports?.length&&(g+=`
Ports: ${T.ports.map(H=>`${H.host}:${H.container}`).join(", ")}
`),T.volumes?.length&&(g+=`
Volumes: ${T.volumes.length}
`),Object.keys(T.environment||{}).length&&(g+=`
Env vars: ${Object.keys(T.environment).length}
`),T.envFileWarning&&(g+=`
\u26A0 ${escapeHtml(T.envFileWarning)}
`),T.resources?.cpus||T.resources?.memory)){const H=[];T.resources.cpus&&H.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&H.push(`Mem: ${T.resources.memory}MB`),g+=`
Limits: ${H.join(", ")}
`}g+="
"}g+="
",P.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!k)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const P=document.getElementById("compose-progress-content");P.innerHTML='
Deploying services...
';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:k.services,networks:k.networks,stackName:k.stackName});let T=`
Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete
`;T+='
';for(const w of g.results){const H=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";T+='
',T+=`${H} ${escapeHtml(w.name)} (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(T+=` ${escapeHtml(w.error)}`),w.subdomain&&(T+=` \u2192 ${escapeHtml(w.subdomain)}`),w.reason&&(T+=` (${escapeHtml(w.reason)})`),T+="
"}T+="
",T+='',P.innerHTML=T,document.getElementById("compose-done-btn")?.addEventListener("click",()=>{h?.classList.remove("show"),typeof window.loadServices=="function"&&window.loadServices().then(()=>{typeof window.buildGrid=="function"&&window.buildGrid()})}),showNotification(`Stack "${g.stackName}" deployed`,"success")}catch(g){P.innerHTML=`
Deployment failed: ${escapeHtml(g.message)}
`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`

Terminal

@@ -1614,11 +1614,11 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
- `);const h=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),P=document.getElementById("exec-close");let w=null,N=null,O=null;function z(){if(N){try{N.close()}catch{}N=null}if(w){try{w.dispose()}catch{}w=null}O=null,E.innerHTML=""}function A(v,L){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||v}`,h?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='
xterm.js not loaded
';return}w=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"&&(O=new FitAddon.FitAddon,w.loadAddon(O)),w.open(E),O&&setTimeout(()=>O.fit(),50);const b=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${b}//${location.host}/ws/exec/${encodeURIComponent(v)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(w.writeln("\x1B[32mConnecting...\x1B[0m"),O){const k=O.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const B=JSON.parse(k.data);if(B.type==="connected"){w.writeln(`\x1B[32mConnected (${B.shell})\x1B[0m\r -`);return}if(B.type==="error"){w.writeln(`\x1B[31mError: ${B.message}\x1B[0m`);return}if(B.type==="exit"){w.writeln(`\r -\x1B[33mSession ended.\x1B[0m`);return}}catch{}w.write(k.data)}else w.write(new Uint8Array(k.data))},N.onclose=()=>{w&&w.writeln(`\r -\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{w&&w.writeln(`\r -\x1B[31mConnection error.\x1B[0m`)},w.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),w.onResize(({cols:k,rows:B})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:B}))});const M=()=>{O&&O.fit()};window.addEventListener("resize",M),h._resizeHandler=M}P?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",v=>{v.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=A})(),(function(){injectModal("audit-modal",`
+
`);const h=document.getElementById("exec-modal"),S=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let k=null,N=null,R=null;function z(){if(N){try{N.close()}catch{}N=null}if(k){try{k.dispose()}catch{}k=null}R=null,S.innerHTML=""}function P(g,T){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||g}`,h?.classList.add("show"),typeof Terminal>"u"){S.innerHTML='
xterm.js not loaded
';return}k=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"&&(R=new FitAddon.FitAddon,k.loadAddon(R)),k.open(S),R&&setTimeout(()=>R.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(g)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(k.writeln("\x1B[32mConnecting...\x1B[0m"),R){const E=R.proposeDimensions();E&&N.send(JSON.stringify({type:"resize",cols:E.cols,rows:E.rows}))}},N.onmessage=E=>{if(typeof E.data=="string"){try{const $=JSON.parse(E.data);if($.type==="connected"){k.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r +`);return}if($.type==="error"){k.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){k.writeln(`\r +\x1B[33mSession ended.\x1B[0m`);return}}catch{}k.write(E.data)}else k.write(new Uint8Array(E.data))},N.onclose=()=>{k&&k.writeln(`\r +\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{k&&k.writeln(`\r +\x1B[31mConnection error.\x1B[0m`)},k.onData(E=>{N&&N.readyState===WebSocket.OPEN&&N.send(E)}),k.onResize(({cols:E,rows:$})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:E,rows:$}))});const H=()=>{R&&R.fit()};window.addEventListener("resize",H),h._resizeHandler=H}A?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",g=>{g.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=P})(),(function(){injectModal("audit-modal",`

\u{1F4DC} Audit Log

- `);const h=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),P=document.getElementById("audit-cancel"),w=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),O=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let v=0;const L=50;async function b(M){try{M||(v=0,z.innerHTML='
Loading...
');const k=O.value;let B=`/api/v1/audit-logs?limit=${L}&offset=${v}`;k&&(B+=`&action=${encodeURIComponent(k)}`);const T=await(await fetch(B)).json(),j=T.success&&T.entries?T.entries:[];if(j.length===0&&!M){z.innerHTML='
\u{1F4DC}No audit log entries yet. Actions will be logged automatically.
',A.style.display="none";return}let H="";M||(H='',H+='');for(const R of j){const x=R.outcome==="success";H+='',H+=``,H+=``,H+=``,H+=``,H+=``,H+="",R.details&&Object.keys(R.details).length>0&&(H+=``)}if(!M)H+="
WhenIPActionResourceResult
${timeAgo(R.timestamp)}${escapeHtml(R.ip||"-")}${escapeHtml(R.action||"-")}${escapeHtml(R.resource||"-")}${x?"\u2713":"\u2717"}
",z.innerHTML=H;else{const R=z.querySelector("table");R&&R.insertAdjacentHTML("beforeend",H)}v+=j.length,A.style.display=j.length>=L?"":"none",z.querySelectorAll(".audit-row").forEach(R=>{R.dataset.wired||(R.dataset.wired="true",R.addEventListener("click",()=>{const x=R.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`
Failed: ${escapeHtml(k.message)}
`}}E?.addEventListener("click",()=>{h?.classList.add("show"),b(!1)}),wireModal(h,P),w?.addEventListener("click",()=>b(!1)),O?.addEventListener("change",()=>b(!1)),A?.addEventListener("click",()=>b(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?b(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch(M){showNotification("Error: "+M.message,"error")}})})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`

Weather Settings

+
`);const h=document.getElementById("audit-modal"),S=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),k=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),P=document.getElementById("audit-load-more");let g=0;const T=50;async function w(H){try{H||(g=0,z.innerHTML='
Loading...
');const E=R.value;let $=`/api/v1/audit-logs?limit=${T}&offset=${g}`;E&&($+=`&action=${encodeURIComponent(E)}`);const L=await(await fetch($)).json(),j=L.success&&L.entries?L.entries:[];if(j.length===0&&!H){z.innerHTML='
\u{1F4DC}No audit log entries yet. Actions will be logged automatically.
',P.style.display="none";return}let M="";H||(M='',M+='');for(const O of j){const x=O.outcome==="success";M+='',M+=``,M+=``,M+=``,M+=``,M+=``,M+="",O.details&&Object.keys(O.details).length>0&&(M+=``)}if(!H)M+="
WhenIPActionResourceResult
${timeAgo(O.timestamp)}${escapeHtml(O.ip||"-")}${escapeHtml(O.action||"-")}${escapeHtml(O.resource||"-")}${x?"\u2713":"\u2717"}
",z.innerHTML=M;else{const O=z.querySelector("table");O&&O.insertAdjacentHTML("beforeend",M)}g+=j.length,P.style.display=j.length>=T?"":"none",z.querySelectorAll(".audit-row").forEach(O=>{O.dataset.wired||(O.dataset.wired="true",O.addEventListener("click",()=>{const x=O.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(E){z.innerHTML=`
Failed: ${escapeHtml(E.message)}
`}}S?.addEventListener("click",()=>{h?.classList.add("show"),w(!1)}),wireModal(h,A),k?.addEventListener("click",()=>w(!1)),R?.addEventListener("change",()=>w(!1)),P?.addEventListener("click",()=>w(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const E=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();E.success?w(!1):showNotification("Error: "+(E.error||"Clear failed"),"error")}catch(H){showNotification("Error: "+H.message,"error")}})})(),(function(){injectModal("security-modal",`
+
+

\u{1F6E1}\uFE0F Security Center

+ + +
+ + + +
+ + +
+
+
\u2014 events
+
\u2014 warnings
+
\u2014 errors
+
\u2014 denied
+
\u2014 hosts
+
+
+
+

Top Actors (24h)

+
\u2014
+
+
+

Top Targets (24h)

+
\u2014
+
+
+
+ + + + + + + + +
+
`);const h=document.getElementById("security-modal"),S=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),k=h.querySelectorAll(".sec-tab"),N=h.querySelectorAll(".sec-panel");let R=[],z=[],P=null;k.forEach(s=>{s.addEventListener("click",()=>{k.forEach(l=>l.classList.toggle("active",l===s)),N.forEach(l=>l.style.display=l.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&H(),s.dataset.tab==="events"&&O(),s.dataset.tab==="hosts"&&m()})}),S&&S.addEventListener("click",()=>{h.classList.add("show"),H(),T()}),A.addEventListener("click",g),h.addEventListener("click",s=>{s.target===h&&g()});function g(){h.classList.remove("show"),w()}function T(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{P=new EventSource("/api/v1/security/events/stream"),P.addEventListener("init",s=>{try{R=JSON.parse(s.data).events||[],x()}catch{}}),P.addEventListener("security",s=>{try{const l=JSON.parse(s.data);R.unshift(l),R.length>500&&(R.length=500);const r=h.querySelector(".sec-tab.active")?.dataset?.tab;r==="events"?x():r==="overview"&&H()}catch{}}),P.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function w(){if(P){try{P.close()}catch{}P=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{h.classList.contains("show")&&T()});async function H(){try{const s=new Date(Date.now()-864e5).toISOString(),[l,r,f]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),u=(await l.json()).data||{},t=(await r.json()).data?.hosts||[],e=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${u.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${u.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${u.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,E("sec-top-actors",u.top_actors||[]),E("sec-top-targets",u.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function E(s,l){const r=document.getElementById(s);if(!l.length){r.innerHTML='
No data
';return}r.innerHTML=''+l.map(f=>``).join("")+"
${p(String(f.key))}${f.count}
"}const $=document.getElementById("sec-filter-source"),I=document.getElementById("sec-filter-severity"),L=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),M=document.getElementById("sec-refresh-btn");[$,I,L].forEach(s=>s.addEventListener("change",O)),j.addEventListener("input",v(O,250)),M.addEventListener("click",O);async function O(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),I.value&&s.set("severity",I.value),L.value&&s.set("source_host",L.value),j.value&&s.set("actor_prefix",j.value),R=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],x(),(!L.options.length||L.options.length===1)&&await y()}catch(s){document.getElementById("sec-events-container").innerHTML='
Load failed: '+p(s.message)+"
"}}function x(){const s=document.getElementById("sec-events-container");if(!R.length){s.innerHTML='
No events
';return}s.innerHTML=R.slice(0,200).map(D).join("")}function D(s){const l=s.severity||"info",r={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[l]||"#7f8c8d",f=s.ts?new Date(s.ts).toLocaleTimeString():"",u=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",o=s.action||"",a=s.outcome||"";return`
+ ${p(l)} + ${p(u)} + ${p(t)} + ${p(o)} ${p(e)} + ${p(a)} + ${p(f)} +
`}async function y(){try{const l=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],r=L.value;L.innerHTML=''+l.map(f=>``).join(""),r&&(L.value=r)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",b),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const l=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=l;const r=document.getElementById("sec-hosts-container");if(!l.length){r.innerHTML='
No hosts registered. Click \u2795 Register Host to add one.
';return}r.innerHTML=l.map(f=>{const u=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`
+
+ ${p(f.label||f.id)} + ${p(f.type)} +
+ id: ${p(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"} +
+
+
+ ${u} + ${f.id==="self"?"":``} +
+
`}).join(""),r.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"}),m())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='
Load failed: '+p(s.message)+"
"}}async function b(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const l=prompt("Display label:",s)||s,r=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:s,label:l,type:r})}),u=await f.json();if(!f.ok){alert("Failed: "+(u?.error?.message||f.statusText));return}alert(`\u2705 Host registered! + +id: ${u.data.host.id} +label: ${u.data.host.label} +type: ${u.data.host.type} + +\u{1F511} API KEY (save this NOW \u2014 won't be shown again): + +${u.data.api_key} + +Send this key as: Authorization: Bearer +To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f){alert("Failed: "+f.message)}}function p(s){return String(s).replace(/[&<>"']/g,l=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[l])}function v(s,l){let r;return function(){clearTimeout(r),r=setTimeout(()=>s.apply(this,arguments),l)}}})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`

Weather Settings

Enter a city name, postal code, or “City, Country”
@@ -1665,23 +1776,23 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
-
`);const E="weather-location",P="weather-zip",w="weather-geo",N="weather-unit";!safeGet(E)&&safeGet(P)&&safeSet(E,safeGet(P));function O(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const A={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function b(H){return L[Math.round(H/22.5)%16]}async function M(H){const R=safeGet(w);if(R)try{const f=JSON.parse(R);if(f.query===H)return f}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(H)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const D=await x.json();if(!D.results||!D.results.length)throw new Error("Location not found");const g=D.results[0],u={query:H,lat:g.latitude,lon:g.longitude,city:g.name,state:g.admin1||"",country:g.country||"",countryCode:g.country_code||""};return safeSet(w,JSON.stringify(u)),u}function k(H){return H.countryCode==="US"&&H.state?`${H.city}, ${H.state}`:H.country?`${H.city}, ${H.country}`:H.city}async function B(H){try{const R=await M(H),x=O(),D=x==="metric"?"celsius":"fahrenheit",g=x==="metric"?"kmh":"mph",u=`https://api.open-meteo.com/v1/forecast?latitude=${R.lat}&longitude=${R.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${g}`,f=await fetch(u);if(!f.ok)throw new Error("Weather fetch failed");const p=(await f.json()).current,d=p.weather_code;return{temp:Math.round(p.temperature_2m),condition:A[d]||"Unknown",icon:v[d]||"\u{1F324}\uFE0F",locationStr:k(R),windSpeed:Math.round(p.wind_speed_10m),windDir:b(p.wind_direction_10m),unit:x}}catch(R){return console.warn("Weather fetch failed:",R),null}}async function S(){const H=z();if(!H.icon||!H.temp||!H.condition||!H.location||!H.wind){console.warn("Weather widget elements not found");return}const R=safeGet(E);if(!R){H.location.textContent="Set Location",H.temp.textContent="--\xB0",H.condition.textContent="Click \u2699\uFE0F to configure",H.wind.textContent="--",H.icon.innerHTML='\u{1F324}\uFE0F';return}try{const x=await B(R);if(x){const D=x.unit==="metric"?"\xB0C":"\xB0F",g=x.unit==="metric"?"km/h":"mph";H.location.textContent=x.locationStr,H.temp.textContent=`${x.temp}${D}`,H.condition.textContent=x.condition,H.wind.textContent=`Wind: ${x.windSpeed} ${g} ${x.windDir}`,H.icon.innerHTML=`${escapeHtml(x.icon)}`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),H.location.textContent="Weather Error",H.temp.textContent="Error",H.condition.textContent="Failed to load",H.wind.textContent="--"}}const T=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const H=O(),R=T.querySelector(`input[name="weather-unit-radio"][value="${H}"]`);R&&(R.checked=!0),T.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const H=j.value.trim();if(H){safeGet(E)!==H&&safeSet(w,""),safeSet(E,H);const x=T.querySelector('input[name="weather-unit-radio"]:checked'),D=x?x.value:"imperial",g=O();safeSet(N,D),g!==D&&safeSet(w,""),T.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",H=>{H.key==="Escape"&&T.classList.contains("show")&&T.classList.remove("show")}),S(),setInterval(S,DC.POLL.WEATHER)})(),(function(){const h=document.getElementById("clock-widget"),E=document.getElementById("clock-render");if(!h||!E)return;const P=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],w=["January","February","March","April","May","June","July","August","September","October","November","December"],N=["XII","I","II","III","IV","V","VI","VII","VIII","IX","X","XI"];let O=safeGet("clock-style")||"default",z=-1,A=!1,v="",L="",b=null,M=null;function k(n){if(A||safeGet("clock-chimes")!=="true")return;A=!0;const e=parseInt(safeGet("clock-chime-volume")||"50",10)/100;let o=0;function a(){if(o>=n){A=!1;return}const r=new Audio("/assets/sounds/church-bell.mp3");r.volume=e,r.play().catch(()=>{}),o++,o{A=!1},2500)}a()}function B(n){return P[n.getDay()]+", "+w[n.getMonth()]+" "+n.getDate()+", "+n.getFullYear()}function S(){L="",b=null}function T(){return L!=="digital"&&(E.innerHTML='
',b={main:E.querySelector(".clock-main"),seconds:E.querySelector(".clock-seconds"),ampm:E.querySelector(".clock-ampm"),date:E.querySelector(".clock-date")},L="digital"),b}function j(n){const e=n.getHours(),o=n.getMinutes(),a=n.getSeconds(),r=e>=12?"PM":"AM",t=e%12||12,s=T();s.main.textContent=`${t}:${String(o).padStart(2,"0")}`,s.seconds.textContent=`:${String(a).padStart(2,"0")}`,s.ampm.textContent=r,s.date.textContent=B(n)}function H(n,e){const o=n.getHours(),a=n.getMinutes(),r=n.getSeconds(),t=o>=12?"PM":"AM",s=o%12||12,l=T();l.main.textContent=`${String(s).padStart(2,"0")}:${String(a).padStart(2,"0")}`,l.seconds.textContent=`:${String(r).padStart(2,"0")}`,l.ampm.textContent=t,l.date.textContent=B(n)}function R(n){const e=n.getHours(),o=n.getMinutes(),a=n.getSeconds(),r=e>=12?"PM":"AM",t=e%12||12,s=String(t).padStart(2," ")+String(o).padStart(2,"0")+String(a).padStart(2,"0");let l='
';if(l+=x(s[0],0),l+=x(s[1],1),l+=':',l+=x(s[2],2),l+=x(s[3],3),l+=':',l+=x(s[4],4),l+=x(s[5],5),l+=`${r}`,l+="
",l+=`
${B(n)}
`,E.innerHTML=l,L="flip",v){for(let C=0;C<6;C++)if(s[C]!==v[C]){const I=E.querySelector(`.flip-card[data-idx="${C}"]`);I&&I.classList.add("flipping")}}v=s}function x(n,e){const o=n===" "?"":n;return`
${o}
${o}
`}function D(n){const e=n.getHours(),o=n.getMinutes(),a=n.getSeconds(),r=e%12||12,t=e>=12?"PM":"AM",s=[Math.floor(r/10),r%10,Math.floor(o/10),o%10,Math.floor(a/10),a%10];let l='
';l+='
HHMMSS
';for(let C=3;C>=0;C--){l+='
';for(let I=0;I<6;I++){const F=s[I]>>C&1;l+=`
`}l+="
"}l+='
';for(let C=0;C<6;C++)l+=`${s[C]}`;l+="
",l+=`
${t}
`,l+="
",l+=`
${B(n)}
`,E.innerHTML=l,L="binary"}function g(n,e){const o=n.getHours(),a=n.getMinutes(),r=n.getSeconds(),t=120,s=t/2,l=t/2,C=r/60*360-90,I=(a+r/60)/60*360-90,F=(o%12+a/60)/12*360-90;let q="";for(let X=1;X<=12;X++){const Q=X/12*2*Math.PI-Math.PI/2,ne=47,oe=s+ne*Math.cos(Q),se=l+ne*Math.sin(Q),Y=e?N[X%12]:X;q+=`${Y}`}let U="";for(let X=0;X<60;X++){const Q=X/60*2*Math.PI-Math.PI/2,ne=56,oe=X%5===0?52:54,se=s+oe*Math.cos(Q),Y=l+oe*Math.sin(Q),ie=s+ne*Math.cos(Q),re=l+ne*Math.sin(Q),ae=X%5===0?1.5:.5;U+=``}const G=` - +
`);const S="weather-location",A="weather-zip",k="weather-geo",N="weather-unit";!safeGet(S)&&safeGet(A)&&safeSet(S,safeGet(A));function R(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const P={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"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(M){return T[Math.round(M/22.5)%16]}async function H(M){const O=safeGet(k);if(O)try{const b=JSON.parse(O);if(b.query===M)return b}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(M)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const D=await x.json();if(!D.results||!D.results.length)throw new Error("Location not found");const y=D.results[0],m={query:M,lat:y.latitude,lon:y.longitude,city:y.name,state:y.admin1||"",country:y.country||"",countryCode:y.country_code||""};return safeSet(k,JSON.stringify(m)),m}function E(M){return M.countryCode==="US"&&M.state?`${M.city}, ${M.state}`:M.country?`${M.city}, ${M.country}`:M.city}async function $(M){try{const O=await H(M),x=R(),D=x==="metric"?"celsius":"fahrenheit",y=x==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${O.lat}&longitude=${O.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${y}`,b=await fetch(m);if(!b.ok)throw new Error("Weather fetch failed");const v=(await b.json()).current,s=v.weather_code;return{temp:Math.round(v.temperature_2m),condition:P[s]||"Unknown",icon:g[s]||"\u{1F324}\uFE0F",locationStr:E(O),windSpeed:Math.round(v.wind_speed_10m),windDir:w(v.wind_direction_10m),unit:x}}catch(O){return console.warn("Weather fetch failed:",O),null}}async function I(){const M=z();if(!M.icon||!M.temp||!M.condition||!M.location||!M.wind){console.warn("Weather widget elements not found");return}const O=safeGet(S);if(!O){M.location.textContent="Set Location",M.temp.textContent="--\xB0",M.condition.textContent="Click \u2699\uFE0F to configure",M.wind.textContent="--",M.icon.innerHTML='\u{1F324}\uFE0F';return}try{const x=await $(O);if(x){const D=x.unit==="metric"?"\xB0C":"\xB0F",y=x.unit==="metric"?"km/h":"mph";M.location.textContent=x.locationStr,M.temp.textContent=`${x.temp}${D}`,M.condition.textContent=x.condition,M.wind.textContent=`Wind: ${x.windSpeed} ${y} ${x.windDir}`,M.icon.innerHTML=`${escapeHtml(x.icon)}`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),M.location.textContent="Weather Error",M.temp.textContent="Error",M.condition.textContent="Failed to load",M.wind.textContent="--"}}const L=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(S)||"";const M=R(),O=L.querySelector(`input[name="weather-unit-radio"][value="${M}"]`);O&&(O.checked=!0),L.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const M=j.value.trim();if(M){safeGet(S)!==M&&safeSet(k,""),safeSet(S,M);const x=L.querySelector('input[name="weather-unit-radio"]:checked'),D=x?x.value:"imperial",y=R();safeSet(N,D),y!==D&&safeSet(k,""),L.classList.remove("show"),I()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(L),document.addEventListener("keydown",M=>{M.key==="Escape"&&L.classList.contains("show")&&L.classList.remove("show")}),I(),setInterval(I,DC.POLL.WEATHER)})(),(function(){const h=document.getElementById("clock-widget"),S=document.getElementById("clock-render");if(!h||!S)return;const A=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],k=["January","February","March","April","May","June","July","August","September","October","November","December"],N=["XII","I","II","III","IV","V","VI","VII","VIII","IX","X","XI"];let R=safeGet("clock-style")||"default",z=-1,P=!1,g="",T="",w=null,H=null;function E(t){if(P||safeGet("clock-chimes")!=="true")return;P=!0;const e=parseInt(safeGet("clock-chime-volume")||"50",10)/100;let o=0;function a(){if(o>=t){P=!1;return}const d=new Audio("/assets/sounds/church-bell.mp3");d.volume=e,d.play().catch(()=>{}),o++,o{P=!1},2500)}a()}function $(t){return A[t.getDay()]+", "+k[t.getMonth()]+" "+t.getDate()+", "+t.getFullYear()}function I(){T="",w=null}function L(){return T!=="digital"&&(S.innerHTML='
',w={main:S.querySelector(".clock-main"),seconds:S.querySelector(".clock-seconds"),ampm:S.querySelector(".clock-ampm"),date:S.querySelector(".clock-date")},T="digital"),w}function j(t){const e=t.getHours(),o=t.getMinutes(),a=t.getSeconds(),d=e>=12?"PM":"AM",n=e%12||12,i=L();i.main.textContent=`${n}:${String(o).padStart(2,"0")}`,i.seconds.textContent=`:${String(a).padStart(2,"0")}`,i.ampm.textContent=d,i.date.textContent=$(t)}function M(t,e){const o=t.getHours(),a=t.getMinutes(),d=t.getSeconds(),n=o>=12?"PM":"AM",i=o%12||12,c=L();c.main.textContent=`${String(i).padStart(2,"0")}:${String(a).padStart(2,"0")}`,c.seconds.textContent=`:${String(d).padStart(2,"0")}`,c.ampm.textContent=n,c.date.textContent=$(t)}function O(t){const e=t.getHours(),o=t.getMinutes(),a=t.getSeconds(),d=e>=12?"PM":"AM",n=e%12||12,i=String(n).padStart(2," ")+String(o).padStart(2,"0")+String(a).padStart(2,"0");let c='
';if(c+=x(i[0],0),c+=x(i[1],1),c+=':',c+=x(i[2],2),c+=x(i[3],3),c+=':',c+=x(i[4],4),c+=x(i[5],5),c+=`${d}`,c+="
",c+=`
${$(t)}
`,S.innerHTML=c,T="flip",g){for(let C=0;C<6;C++)if(i[C]!==g[C]){const B=S.querySelector(`.flip-card[data-idx="${C}"]`);B&&B.classList.add("flipping")}}g=i}function x(t,e){const o=t===" "?"":t;return`
${o}
${o}
`}function D(t){const e=t.getHours(),o=t.getMinutes(),a=t.getSeconds(),d=e%12||12,n=e>=12?"PM":"AM",i=[Math.floor(d/10),d%10,Math.floor(o/10),o%10,Math.floor(a/10),a%10];let c='
';c+='
HHMMSS
';for(let C=3;C>=0;C--){c+='
';for(let B=0;B<6;B++){const F=i[B]>>C&1;c+=`
`}c+="
"}c+='
';for(let C=0;C<6;C++)c+=`${i[C]}`;c+="
",c+=`
${n}
`,c+="
",c+=`
${$(t)}
`,S.innerHTML=c,T="binary"}function y(t,e){const o=t.getHours(),a=t.getMinutes(),d=t.getSeconds(),n=120,i=n/2,c=n/2,C=d/60*360-90,B=(a+d/60)/60*360-90,F=(o%12+a/60)/12*360-90;let q="";for(let X=1;X<=12;X++){const Q=X/12*2*Math.PI-Math.PI/2,ne=47,oe=i+ne*Math.cos(Q),se=c+ne*Math.sin(Q),Y=e?N[X%12]:X;q+=`${Y}`}let U="";for(let X=0;X<60;X++){const Q=X/60*2*Math.PI-Math.PI/2,ne=56,oe=X%5===0?52:54,se=i+oe*Math.cos(Q),Y=c+oe*Math.sin(Q),ie=i+ne*Math.cos(Q),re=c+ne*Math.sin(Q),ae=X%5===0?1.5:.5;U+=``}const G=` + ${U} ${q} - - - - - `,J=n.getHours()>=12?"PM":"AM";E.innerHTML=`
${G}
${n.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}${B(n)}
`,L="analog"}function u(){const n=new Date,e=n.getHours()%12||12,o=n.getMinutes(),a=n.getSeconds(),r="clock-widget"+(O!=="default"?" "+O:"");switch(h.className!==r&&(h.className=r),O){case"lcd":H(n);break;case"lcd-blue":H(n);break;case"lcd-amber":H(n);break;case"lcd-retro":H(n);break;case"lcd-taxi":H(n);break;case"flip":R(n);break;case"binary":D(n);break;case"analog":g(n,!1);break;case"roman":g(n,!0);break;default:j(n)}o===0&&a===0&&e!==z&&(z=e,k(e)),o!==0&&(z=-1)}function f(){clearTimeout(M);const n=document.hidden?6e4:1e3,e=n-Date.now()%n+25;M=setTimeout(()=>{u(),f()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),u(),f()}),u(),f();const m=[{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 p='
';m.forEach(n=>{p+=``}),p+="
",injectModal("clock-settings-modal",`
+ + + + + `,J=t.getHours()>=12?"PM":"AM";S.innerHTML=`
${G}
${t.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}${$(t)}
`,T="analog"}function m(){const t=new Date,e=t.getHours()%12||12,o=t.getMinutes(),a=t.getSeconds(),d="clock-widget"+(R!=="default"?" "+R:"");switch(h.className!==d&&(h.className=d),R){case"lcd":M(t);break;case"lcd-blue":M(t);break;case"lcd-amber":M(t);break;case"lcd-retro":M(t);break;case"lcd-taxi":M(t);break;case"flip":O(t);break;case"binary":D(t);break;case"analog":y(t,!1);break;case"roman":y(t,!0);break;default:j(t)}o===0&&a===0&&e!==z&&(z=e,E(e)),o!==0&&(z=-1)}function b(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{m(),b()},e)}document.addEventListener("visibilitychange",()=>{g="",I(),m(),b()}),m(),b();const p=[{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 v='
';p.forEach(t=>{v+=``}),v+="
",injectModal("clock-settings-modal",`

Clock Settings

- ${p} + ${v}
-
`);const d=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),i=document.getElementById("clock-chime-volume"),$=document.getElementById("clock-volume-section");function y(){const n=safeGet("clock-style")||"default",e=d.querySelector(`input[value="${n}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",i.value=safeGet("clock-chime-volume")||"50",$.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{$.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),d.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(i.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=n,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const n=d.querySelector('input[name="clock-style-radio"]:checked'),e=n?n.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",i.value),O=e,v="",S(),u(),f(),d.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{d.classList.remove("show")}),wireModal(d),d?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{O=n.value,v="",S(),u()})})})(),(function(){async function h(){try{const A=await(await fetch("/api/v1/health-checks/status")).json();if(!A.success||!A.status)return;for(const[v,L]of Object.entries(A.status)){const b=document.getElementById("uptime-"+v),M=document.getElementById("uptime-bar-"+v);if(!b)continue;const k=L.uptime?.["24h"];if(k!=null){const B=k.toFixed(1);b.textContent=`${B}% uptime`,b.className="uptime-chip",k>=99.9?b.classList.add("excellent"):k>=99?b.classList.add("good"):k>=95?b.classList.add("degraded"):b.classList.add("poor"),M&&(M.style.width=B+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}let P=[];async function w(z){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(b=>b.classList.remove("visible"));const L=v.updates||[];if(P=L,z&&L.length>0){const b=window._lastKnownUpdateCount||0;b>0&&L.length>b&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const b of L){const M=window.APPS||[];for(const k of M)if(k.containerId===b.containerId||k.id===b.containerName||k.name===b.containerName){if(E.has(k.id))break;const B=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);B&&(B.classList.add("visible"),B.title="Update available \u2014 click to open Update Management.",B.style.cursor="pointer",B.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(()=>{h(),w()},5e3),setInterval(()=>{h(),w(!0)},6e4)}const O=window.refreshAll;O&&(window.refreshAll=async function(){try{await O(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,E=null,P={},w={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"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],O=document.getElementById("theme");if(!O)return;var z=document.getElementById("theme-label");function A(a){if(w[a])return w[a];var r=safeGetJSON(window.USER_THEMES_KEY,{});return r[a]&&r[a].name||a}function v(){z&&(z.textContent=A(window.getActiveTheme()))}O.addEventListener("click",function(){var a=window.THEMES.slice(),r=window.getActiveTheme(),t=a.indexOf(r),s=a[(t+1)%a.length];window.applyTheme(s),v()}),v();function L(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},r={};N.forEach(function(s){r[s[2]]||(r[s[2]]=[]),r[s[2]].push(s)});var t="";return Object.keys(a).forEach(function(s){s==="advanced"?(t+='
Show advanced colors ▼
',t+='`).join("")}async function M(){try{const p=await(await fetch("/api/v1/license/status")).json();p.success&&(j(p.license),D(p.license))}catch(b){console.warn("Failed to load license status:",b.message)}}async function O(){const b=S.value.trim();if(!b){I("Please enter a license code.");return}$(),A.disabled=!0,A.textContent="Activating...";try{const v=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();v.success?(L(v.message),S.value="",j(v.license),showNotification("License activated! Premium features unlocked.","success",5e3),D(v.license)):I(v.error||"Activation failed")}catch(p){I("Network error: "+p.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){k.disabled=!0,k.textContent="Deactivating...";try{const p=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();p.success?(L(p.message),await M(),showNotification("License deactivated.","info",3e3),D({active:!1})):I(p.error||"Deactivation failed")}catch(b){I("Network error: "+b.message)}finally{k.disabled=!1,k.textContent="Deactivate"}}}function D(b){const p=document.getElementById("license-status-topbar"),v=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),l=document.getElementById("license-topbar-time");if(p)if(p.className="license-status-topbar "+(b.active?"premium":"free"),b.active)if(v.textContent="\u2605",s.textContent="PREMIUM",b.lifetime)l.textContent="\xB7 LIFETIME";else{const r=b.daysRemaining;l.textContent=r!=null?"\xB7 "+r+"d remaining":""}else v.textContent="\u2606",s.textContent=b.expired?"EXPIRED":"FREE TIER",l.textContent=""}function y(){$(),M(),h.classList.add("show")}S.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 p=["DC"],v=b.substring(2);for(let s=0;s{b.key==="Enter"&&O()}),wireModal(h,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=y,window.checkPremiumFeature=async function(b){try{return(await(await fetch(`/api/v1/license/feature/${b}`)).json()).available}catch{return!1}},M().then(b=>{E&&D(E)})})(); diff --git a/status/dist/init.js b/status/dist/init.js index 574acae..4b81bf6 100644 --- a/status/dist/init.js +++ b/status/dist/init.js @@ -1,4 +1,4 @@ -(function(){function v(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(e=>{window.APPS.find(n=>n.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const a=document.querySelectorAll(".top .card");a.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function E(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(e=>{const n=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[n]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(n&&n.tourCompleted)}catch{}const i=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=a?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const n=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(n),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(n),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const a=await fetch("/api/v1/templates",{cache:"no-store"});if(!a.ok)return;const i=await a.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(a){console.warn("[init] Failed to load template categories:",a)}}function q(){const a=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;a&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",n=i.querySelector('option[value=""]');if(i.innerHTML="",n)i.appendChild(n);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(a).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(i.success&&i.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}f()})()})(),(function(){const v=document.createElement("style");v.textContent=` +(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(n.success&&n.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(i){console.warn("TOTP check failed, proceeding normally:",i)}f()})()})(),(function(){const v=document.createElement("style");v.textContent=` .dc-monitor { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); @@ -109,7 +109,7 @@
\u2014
\u2014
- `,k.parentNode.insertBefore(u,k);function h(e,n){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(n)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c{l.dataset.status==="on"&&n++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${n} online \xB7 ${e-n} offline`)}function E(e){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!e||e.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;n.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='\u25CF all healthy':m<=2?c.innerHTML=`\u25CF ${m} degraded`:c.innerHTML=`\u25CF ${m} down`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const n=await e.json();return n&&n.stats?n.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){n&&(n.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(n&&(n.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let a=!1;async function i(){if(!a){a=!0;try{C();const[e,n]=await Promise.all([S(),y()]);q(e),E(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=` + `,k.parentNode.insertBefore(u,k);function h(e,a){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(a)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const a=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c{l.dataset.status==="on"&&a++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${a} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${a} online \xB7 ${e-a} offline`)}function E(e){const a=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!a)return;if(!e||e.summary==null){a.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;a.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='\u25CF all healthy':m<=2?c.innerHTML=`\u25CF ${m} degraded`:c.innerHTML=`\u25CF ${m} down`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const a=await e.json();return a&&a.stats?a.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const a=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){a&&(a.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){a&&(a.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(a&&(a.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let i=!1;async function n(){if(!i){i=!0;try{C();const[e,a]=await Promise.all([S(),y()]);q(e),E(a);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{i=!1}}}window.refreshMonitoringWidgets=n,setInterval(n,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(n,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
\u{1F50D} @@ -271,7 +271,7 @@ font-family: monospace; margin-right: 4px; } - `,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),a()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function a(){const t=` + `,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function i(){const t=`
Quick Actions
\u{1F504} @@ -303,8 +303,8 @@
Services
- ${i()} - `;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=` + ${n()} + `;f.innerHTML=t,s()}function n(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
${d==="on"?"\u{1F7E2}":"\u{1F534}"}
@@ -313,7 +313,7 @@
${d.toUpperCase()}
- `)}),o||'
No services found
'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){a();return}const r=n(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function n(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='
Actions
',t.actions.forEach(r=>{o+=` + `)}),o||'
No services found
'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){i();return}const r=a(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function a(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='
Actions
',t.actions.forEach(r=>{o+=`
${r.icon}
diff --git a/status/js/admin.js b/status/js/admin.js new file mode 100644 index 0000000..7ed2d77 --- /dev/null +++ b/status/js/admin.js @@ -0,0 +1,461 @@ +/** + * Admin panel — DC-048. + * + * Minimal admin UI for managing users + invites. Rendered as a modal overlay + * triggered by an "Admin" button in the top bar that only appears when + * /api/v1/auth/me returns isAdmin=true. The panel renders three sections: + * + * 1. Users — list of authorized users with role badges, role-edit, + * delete actions. + * 2. Invite a user — form to issue a single-use invite (email, role, + * TTL). The accept-link is shown post-issue so the admin can copy it. + * 3. Outstanding invites — list of issued-not-yet-accepted invites + * with a revoke button. + * + * The panel does NOT add a tab to the dashboard nav — it lives as a modal + * to keep DC-048 surgical. Future DC-049 work can promote it to a tab. + * + * Behaviour: + * - On load: GET /me; if !isAdmin → show "admin only" placeholder + * - Then GET /admin/users + /admin/invites in parallel + * - Forms POST to the admin endpoints, refresh lists on success + * - "Copy link" button writes the acceptUrl to the clipboard + * + * Wires into the global error-handler (window.errorHandler) for failure + * surfaces. Uses window.SITE for any UI constants (none currently). + */ + +(function () { + 'use strict'; + + const API = { + me: '/api/v1/auth/me', + users: '/api/v1/auth/admin/users', + allowlist: '/api/v1/auth/admin/allowlist', + invites: '/api/v1/auth/admin/invites', + }; + + function _el(tag, attrs, ...children) { + const node = document.createElement(tag); + if (attrs) { + for (const k of Object.keys(attrs)) { + const v = attrs[k]; + if (v === null || v === undefined || v === false) continue; + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k === 'html') node.innerHTML = v; + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v); + else node.setAttribute(k, v); + } + } + for (const c of children) { + if (c === null || c === undefined || c === false) continue; + if (typeof c === 'string') node.appendChild(document.createTextNode(c)); + else node.appendChild(c); + } + return node; + } + + async function _fetchJSON(url, opts) { + const csrf = (window.SITE && window.SITE.csrfToken) || ''; + opts = opts || {}; + opts.headers = Object.assign( + { 'Content-Type': 'application/json' }, + opts.headers || {}, + csrf ? { 'X-CSRF-Token': csrf } : {} + ); + if (opts.body && typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body); + const r = await fetch(url, opts); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + const msg = (data && (data.message || data.error)) || ('HTTP ' + r.status); + const err = new Error(msg); + err.status = r.status; + throw err; + } + return data; + } + + function _renderBadge(role) { + const colors = { + admin: 'background:#7c3aed;color:#fff', + operator: 'background:#2563eb;color:#fff', + viewer: 'background:#6b7280;color:#fff', + }; + return _el('span', { + class: 'role-badge', + style: 'display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;' + + (colors[role] || colors.viewer), + text: role, + }); + } + + function _renderUsersList(container, users, onChange) { + container.innerHTML = ''; + if (!users || users.length === 0) { + container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No users yet.' })); + return; + } + const table = _el('table', { + style: 'width:100%;border-collapse:collapse;font-size:0.9rem', + }); + table.appendChild(_el('thead', null, + _el('tr', { style: 'border-bottom:1px solid var(--border)' }, + _el('th', { style: 'text-align:left;padding:8px', text: 'Email' }), + _el('th', { style: 'text-align:left;padding:8px', text: 'Role' }), + _el('th', { style: 'text-align:left;padding:8px', text: 'Created' }), + _el('th', { style: 'text-align:left;padding:8px', text: 'Last login' }), + _el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }), + ), + )); + const tbody = _el('tbody'); + for (const u of users) { + const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' }); + + const emailCell = _el('td', { style: 'padding:8px' }); + emailCell.appendChild(_el('span', { text: u.email || '(no email)' })); + if (u.displayName && u.displayName !== (u.email || '').split('@')[0]) { + emailCell.appendChild(_el('br')); + emailCell.appendChild(_el('small', { + style: 'color:var(--muted)', text: u.displayName, + })); + } + row.appendChild(emailCell); + + const roleCell = _el('td', { style: 'padding:8px' }); + roleCell.appendChild(_renderBadge(u.role)); + row.appendChild(roleCell); + + row.appendChild(_el('td', { + style: 'padding:8px;color:var(--muted);font-size:0.85rem', + text: u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—', + })); + row.appendChild(_el('td', { + style: 'padding:8px;color:var(--muted);font-size:0.85rem', + text: u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '—', + })); + + const actionsCell = _el('td', { style: 'padding:8px;text-align:right' }); + const roleSelect = _el('select', { + style: 'padding:2px 6px;margin-right:6px', + onchange: async (ev) => { + try { + await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), { + method: 'PATCH', body: { role: ev.target.value }, + }); + onChange && onChange(); + } catch (e) { + window.errorHandler && window.errorHandler.show('Role update failed: ' + e.message); + ev.target.value = u.role; + } + }, + }); + for (const r of ['admin', 'operator', 'viewer']) { + const opt = _el('option', { value: r, text: r }); + if (r === u.role) opt.selected = true; + roleSelect.appendChild(opt); + } + actionsCell.appendChild(roleSelect); + + const delBtn = _el('button', { + class: 'btn-sm', style: 'padding:2px 8px', + text: 'Delete', + onclick: async () => { + if (!confirm('Delete user ' + (u.email || u.id) + '? This cannot be undone.')) return; + try { + await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), { method: 'DELETE' }); + onChange && onChange(); + } catch (e) { + window.errorHandler && window.errorHandler.show('Delete failed: ' + e.message); + } + }, + }); + actionsCell.appendChild(delBtn); + row.appendChild(actionsCell); + + tbody.appendChild(row); + } + table.appendChild(tbody); + container.appendChild(table); + } + + function _renderInviteForm(container, onIssued) { + const form = _el('form', { + style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:end', + onsubmit: async (ev) => { + ev.preventDefault(); + const fd = new FormData(ev.target); + const body = { + email: fd.get('email'), + role: fd.get('role'), + ttlHours: parseInt(fd.get('ttlHours'), 10) || 24, + sendEmail: fd.get('sendEmail') === 'on', + }; + try { + const r = await _fetchJSON(API.invites, { method: 'POST', body }); + ev.target.reset(); + onIssued && onIssued(r); + } catch (e) { + window.errorHandler && window.errorHandler.show('Invite failed: ' + e.message); + } + }, + }); + form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' }, + _el('span', { text: 'Email' }), + _el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }), + )); + const roleSel = _el('select', { name: 'role', style: 'padding:6px' }); + for (const r of ['operator', 'viewer', 'admin']) { + roleSel.appendChild(_el('option', { value: r, text: r })); + } + form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' }, + _el('span', { text: 'Role' }), roleSel, + )); + form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' }, + _el('span', { text: 'TTL (hours)' }), + _el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }), + )); + form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' }, + _el('input', { name: 'sendEmail', type: 'checkbox', checked: true }), + _el('span', { text: 'Send email' }), + )); + form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' })); + container.appendChild(form); + } + + function _renderInvitesList(container, invites, onChange) { + container.innerHTML = ''; + if (!invites || invites.length === 0) { + container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No outstanding invites.' })); + return; + } + const table = _el('table', { + style: 'width:100%;border-collapse:collapse;font-size:0.9rem', + }); + table.appendChild(_el('thead', null, + _el('tr', { style: 'border-bottom:1px solid var(--border)' }, + _el('th', { style: 'text-align:left;padding:8px', text: 'Email' }), + _el('th', { style: 'text-align:left;padding:8px', text: 'Role' }), + _el('th', { style: 'text-align:left;padding:8px', text: 'Invited by' }), + _el('th', { style: 'text-align:left;padding:8px', text: 'Expires' }), + _el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }), + ), + )); + const tbody = _el('tbody'); + for (const inv of invites) { + const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' }); + row.appendChild(_el('td', { style: 'padding:8px', text: inv.email })); + row.appendChild(_el('td', { style: 'padding:8px' }, _renderBadge(inv.role))); + row.appendChild(_el('td', { style: 'padding:8px;color:var(--muted)', text: inv.invitedBy || '—' })); + row.appendChild(_el('td', { + style: 'padding:8px;color:var(--muted);font-size:0.85rem', + text: inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : '—', + })); + const actionsCell = _el('td', { style: 'padding:8px;text-align:right' }); + actionsCell.appendChild(_el('button', { + class: 'btn-sm', style: 'padding:2px 8px', + text: 'Revoke', + onclick: async () => { + if (!confirm('Revoke invite for ' + inv.email + '?')) return; + try { + await _fetchJSON(API.invites + '/' + encodeURIComponent(inv.id), { method: 'DELETE' }); + onChange && onChange(); + } catch (e) { + window.errorHandler && window.errorHandler.show('Revoke failed: ' + e.message); + } + }, + })); + row.appendChild(actionsCell); + tbody.appendChild(row); + } + table.appendChild(tbody); + container.appendChild(table); + } + + function _renderIssuedInviteBanner(invite, parent) { + const banner = _el('div', { + style: 'margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem', + }); + banner.appendChild(_el('strong', { text: 'Invite issued — copy the link below. ' + + 'It will not be shown again.' })); + banner.appendChild(_el('br')); + banner.appendChild(_el('code', { + style: 'display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5', + text: invite.acceptUrl, + })); + const copyBtn = _el('button', { + class: 'btn-sm', style: 'margin-top:8px;padding:4px 10px', + text: 'Copy link', + onclick: async () => { + try { + await navigator.clipboard.writeText(invite.acceptUrl); + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 2000); + } catch (e) { + window.errorHandler && window.errorHandler.show('Clipboard blocked: select the link manually.'); + } + }, + }); + banner.appendChild(copyBtn); + if (invite.deliveredVia === 'dev-console') { + banner.appendChild(_el('p', { + style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem', + text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).', + })); + } else if (invite.deliveredVia === 'email') { + banner.appendChild(_el('p', { + style: 'margin-top:8px;color:#86efac;font-size:0.8rem', + text: 'Email sent to ' + invite.email + '.', + })); + } + parent.appendChild(banner); + } + + /** + * Mount the admin panel. Called by the open trigger; safe to call multiple + * times (re-renders into the same container). + */ + async function mount(container) { + container.innerHTML = ''; + container.appendChild(_el('h2', { style: 'margin:0 0 16px', text: 'Admin · Users & Invites' })); + + const meData = await _fetchJSON(API.me).catch(() => ({})); + if (!meData || !meData.user || meData.user.role !== 'admin') { + container.appendChild(_el('p', { + style: 'color:var(--muted)', + text: 'Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me.', + })); + return; + } + + // Refresh button + const refreshBtn = _el('button', { + class: 'btn-sm', style: 'float:right;padding:4px 10px', + text: 'Refresh', + onclick: () => mount(container), + }); + container.appendChild(refreshBtn); + + // ── Users section ──────────────────────────────────────────────────── + const usersHeader = _el('h3', { style: 'margin:24px 0 8px;clear:both', text: 'Users' }); + container.appendChild(usersHeader); + + const usersList = _el('div', { id: 'admin-users-list' }); + container.appendChild(usersList); + + const usersData = await _fetchJSON(API.users).catch(() => ({ users: [] })); + _renderUsersList(usersList, usersData.users, () => mount(container)); + + // Add user form (pre-authorize an email without issuing an invite). + container.appendChild(_el('h4', { style: 'margin:24px 0 8px;font-size:0.95rem', text: 'Pre-authorize email' })); + const addUserForm = _el('form', { + style: 'display:flex;gap:8px;align-items:end', + onsubmit: async (ev) => { + ev.preventDefault(); + const email = ev.target.email.value.trim(); + if (!email) return; + try { + await _fetchJSON(API.users, { method: 'POST', body: { email } }); + ev.target.reset(); + mount(container); + } catch (e) { + window.errorHandler && window.errorHandler.show('Add failed: ' + e.message); + } + }, + }); + addUserForm.appendChild(_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' })); + addUserForm.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Add to allowlist' })); + container.appendChild(addUserForm); + + // ── Invites section ────────────────────────────────────────────────── + container.appendChild(_el('h3', { style: 'margin:24px 0 8px', text: 'Issue invite' })); + const inviteFormContainer = _el('div'); + container.appendChild(inviteFormContainer); + + const invitesList = _el('div', { id: 'admin-invites-list', style: 'margin-top:16px' }); + container.appendChild(invitesList); + + const invitesData = await _fetchJSON(API.invites).catch(() => ({ invites: [] })); + _renderInvitesList(invitesList, invitesData.invites, () => mount(container)); + + _renderInviteForm(inviteFormContainer, (issued) => { + _renderIssuedInviteBanner(issued, inviteFormContainer); + mount(container); + }); + } + + // ── Public API ──────────────────────────────────────────────────────── + + /** + * Open the admin panel as a modal overlay. Closes on backdrop click or + * the close button. Renders into document.body so it floats above the + * dashboard chrome. + */ + async function open() { + // If already open, just focus. + const existing = document.getElementById('admin-panel-root'); + if (existing) return; + + const backdrop = _el('div', { + id: 'admin-panel-root', + style: 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;', + onclick: (ev) => { + if (ev.target === backdrop) close(); + }, + }); + + const card = _el('div', { + style: 'background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)', + }); + card.appendChild(_el('button', { + class: 'btn-sm', style: 'position:absolute;top:12px;right:12px;padding:4px 10px', + text: 'Close', onclick: close, + })); + + const body = _el('div', { id: 'admin-panel-body' }); + card.appendChild(body); + backdrop.appendChild(card); + document.body.appendChild(backdrop); + + try { + await mount(body); + } catch (e) { + body.innerHTML = '

Failed to load admin panel: ' + (e.message || e) + '

'; + } + } + + function close() { + const existing = document.getElementById('admin-panel-root'); + if (existing) existing.remove(); + } + + /** + * Inject an "Admin" button into the top bar. Only renders when the + * current /me response says isAdmin=true. Re-checks periodically + * (every 60s) so a permission downgrade takes effect without a reload. + */ + async function attachTrigger(barContainer) { + async function _maybeShow() { + const me = await _fetchJSON(API.me).catch(() => ({})); + const existing = document.getElementById('admin-trigger-btn'); + if (me && me.user && me.user.role === 'admin') { + if (existing) return; + const btn = _el('button', { + id: 'admin-trigger-btn', + class: 'btn-sm', + style: 'margin-left:8px;padding:6px 12px', + text: 'Admin', + onclick: open, + }); + if (barContainer) barContainer.appendChild(btn); + else if (document.body) document.body.appendChild(btn); + } else if (existing) { + existing.remove(); + } + } + await _maybeShow(); + setInterval(_maybeShow, 60_000); + } + + window.AdminPanel = { open, close, attachTrigger }; +})(); \ No newline at end of file diff --git a/status/js/core/init.js b/status/js/core/init.js index 34c86ab..bc5d6ba 100644 --- a/status/js/core/init.js +++ b/status/js/core/init.js @@ -84,6 +84,17 @@ if (shouldLoadOnboarding()) { loadOnboarding(); } + + // DC-048: inject the "Admin" trigger button into the top bar. The + // button only renders when /me returns isAdmin=true; the module + // re-checks every 60s so a permission downgrade takes effect. + if (window.AdminPanel && typeof window.AdminPanel.attachTrigger === 'function') { + try { + await window.AdminPanel.attachTrigger(document.body); + } catch (e) { + console.warn('[init] AdminPanel attachTrigger failed:', e); + } + } } // Lazy-load onboarding bundle (52 KB) — only loaded when needed diff --git a/status/sw.js b/status/sw.js index 563d0b0..0da6729 100644 --- a/status/sw.js +++ b/status/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'dashcaddy-shell-79829761c4'; +const CACHE = 'dashcaddy-shell-1b7c08184e'; const PRECACHE = [ '/', '/index.html',