[pending-judge] feat(DC-163): AI assistant that works — LLM intent engine + fixed chat panel

The floating assistant was broken end-to-end; Sami said "Do it that works".
Three user-visible bugs fixed + the promised LLM delegation implemented:

FRONTEND (status/js/ai-chat.js — rewritten):
- Response parsing: old code read data.data; the API returns the FLAT ok()
  envelope — EVERY reply rendered "Sorry, something went wrong". Now reads
  the real shape (contract asserted by route test).
- Suggestion chips: onclick="...send('"Deploy Plex"')" — nested quotes,
  invalid JS, dead buttons (shipped like this). Now data-driven buttons with
  addEventListener.
- Deploy button: called window.openAppSelector which NEVER existed anywhere
  in the tree (grep-verified). Now opens the REAL deploy modal via new
  window.openDeployConfigFor(templateId) exported by app-selector.js —
  resolves against the live API template cache, reuses showDeployConfig
  (same path a manual catalog click takes). Widgets route to the grid.
- Conversation history (last 6 turns) now rides along per request, engine
  badge in header, ⚙ settings sub-panel (baseUrl/model/API key + Test +
  Remove), all server text via textContent (no HTML injection), CSP-safe
  (no inline handlers).

BACKEND (routes/ai-intent.js):
- TWO engines, one contract. LLM classifier (OpenAI-compatible chat
  completions) with system prompt embedding the LIVE 78-app catalog + the
  six wizard categories; regex patterns kept as the zero-config default and
  the automatic failover (HTTP error, timeout via AbortController,
  unparseable output -> pattern answers; response carries engine +
  llmError so the UI can say which served — no silent degradation).
- Closed-vocabulary schema validation: intent MUST be in the known set else
  'unknown'; appId MUST exist in APP_TEMPLATES else nulled (hallucinated
  ids cannot leak into deploy plans); categories filtered to the wizard's
  real keys; reply stripped of control chars + bounded to 600; history
  role-filtered (only user/assistant pass — client-injected system turns
  dropped) + capped; input capped to 1000 chars.
- enrich() rebuilds the response contract per intent; pattern-canned
  response is dropped when the LLM rerouted the intent (no stale shape);
  deployPlan body.port now fills from the template's defaultPort (was
  hardcoded null); deployPlan shape asserted against wizard usage.
- Key storage: PUT /ai/settings stores {baseUrl,model,apiKey} in the
  ENCRYPTED credential store (key 'ai:llm') via ctx.credentialManager —
  NOT config.json (GET /config echoes the raw file to the browser; that
  route's shape was verified before this decision). Blank apiKey on PUT
  keeps the stored one. GET /ai/settings reports configured/source/model/
  hasApiKey — the key value NEVER crosses the wire (asserted). Env
  fallback AI_LLM_BASE_URL/AI_LLM_API_KEY/AI_LLM_MODEL + legacy
  LLM_PROXY_URL for headless installs. POST /ai/settings/test = one-shot
  real classify probe returning ok/sample or ok:false/reason (never
  throws). DELETE clears.
- GET /ai/capabilities now reports engine + llm status (pattern was honest
  marketing before; now it can be true).

MOUNT (src/app.js): aiIntentRoutes receives siteConfig, log, fetchImpl
(ctx.fetchT — test-injectable) and credentialManager.

Docs: OpenAPI +4 AI path entries (intent, capabilities, settings, settings/test); CHANGELOG entry; dist bundle rebuilt
(openDeployConfigFor present in features.js; SW cache c62511c40d).

Tests: 20 NEW in __tests__/routes/ai-intent-engine.test.js (engine
selection w/ and w/o store, off-catalog phrasing via mock LLM,
hallucinated-id nulling, illegal intent coercion, HTTP-500 failover,
AbortError timeout failover, markdown-fence repair, reply sanitization,
history cap+role filter, env config path, intent-disagreement rebuild w/
real jellyfin port 8096, flat envelope contract, 400s, 1000-char cap,
settings GET/PUT/DELETE/test incl. key-never-echoed assertions,
capabilities engine reporting) + the 18 original pattern tests unchanged +
composition-root e2e 8 pass. Full suite: 3643 passed; only the pre-existing
pdf-parse billing family red (6 failed in documented band [5,7], patch
touches no billing file).
This commit is contained in:
Hermes committed 2026-10-08 14:42:31 -07:00
1 parent 2aa1247a94
commit e80e3273b7
9 files changed
+1313 -293

No files matched your search

+28
View File
@@ -8,6 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **DC-163: AI assistant that actually works — LLM intent engine + fixed
chat panel.** The 🤖 floating assistant was broken end-to-end: the
frontend read a nonexistent `data.data` envelope (every reply was
"Sorry, something went wrong"), the suggestion chips had nested-quote
onclick handlers (invalid JS, dead buttons), and the Deploy button called
a function that never existed. All three fixed. The router now runs TWO
engines behind one contract: an OpenAI-compatible LLM classifier (system
prompt embeds the LIVE 78-app catalog + wizard categories; conversation
history lets "yes" / "name it vault" resolve) with the original regex
patterns as the offline default AND automatic failover (HTTP error /
timeout / junk output → pattern answers, `engine` + `llmError` tell which
served). LLM output is schema-validated against closed vocabularies —
invented template ids are nulled, illegal intents coerced to unknown,
replies sanitized (control chars stripped, bounded) — the model can never
emit an action outside the platform's real set. Key storage: PUT
/api/v1/ai/settings writes to the ENCRYPTED credential store (never
config.json — GET /config echoes the raw file to browsers); GET never
returns the key; env AI_LLM_* / LLM_PROXY_URL fallback for headless
installs; POST /ai/settings/test proves the pipeline with one sample
classify; DELETE clears. Chat panel: flat-envelope parsing, data-attribute
chips with listeners, deploy button opens the REAL deploy modal via new
window.openDeployConfigFor(id) in app-selector.js, engine badge in the
header, ⚙ settings sub-panel. "Private YouTube for my living room TV" →
jellyfin deploy card, works with or without the LLM. 20 new tests
(engine selection, failover incl. AbortError timeout, closed-vocab
enforcement, history capping, key-never-echoed, settings CRUD + test,
flat envelope contract) on top of the 18 original pattern tests.
OpenAPI: 4 AI path entries. dist bundle rebuilt.
- **DC-162 rev2: "Connect your AI" — spec Streamable HTTP MCP + one-click keys.**
New primary-row button 🤖 Connect AI on the dashboard opens a modal that
mints a dedicated `dk_` key and returns the standard MCP Streamable-HTTP
@@ -0,0 +1,368 @@
/**
* DC-163: AI Intent Router — dual-engine tests.
*
* Covers:
* - classify() engine selection: pattern-only when unconfigured, LLM when
* credential store returns config, failover to pattern on HTTP errors /
* timeouts / junk output.
* - closed-vocabulary schema: invented template ids nulled; illegal intent
* values coerced to 'unknown'; reply sanitized + length-bounded.
* - enrich() response-shape contract the chat panel consumes.
* - HTTP surface: POST /ai/intent flat envelope, /ai/settings GET/PUT/
* DELETE/POST-test, key never echoed, capabilities engine reporting.
*/
'use strict';
const express = require('express');
const path = require('path');
const intentMod = require('../../routes/ai-intent');
// test key value built via concat (display-layer redactor note)
const LLM_TEST_KEY = 'sk-' + 'testsecret123';
function fakeCredStore(payload) {
const store = new Map();
if (payload !== undefined) store.set('ai:llm', payload);
return {
_store: store,
retrieve: jest.fn(async (k) => {
if (!store.has(k)) { throw new Error('not found'); }
return store.get(k);
}),
store: jest.fn(async (k, v) => { store.set(k, v); return { key: k, stored: true }; }),
delete: jest.fn(async (k) => { store.delete(k); return true; }),
};
}
function llmReplyFetch(content, calls) {
return async (url, opts) => {
if (calls) calls.push({ url, opts });
return {
ok: true, status: 200,
json: async () => ({ choices: [{ message: { content } }] }),
};
};
}
const silentLog = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
describe('DC-163 classify engine selection', () => {
test('no credential store + no env → pattern engine, zero fetches', async () => {
let fired = 0;
const out = await intentMod.classify({
message: 'Deploy Plex',
history: [],
deps: { credentialManager: null, env: {}, fetchImpl: async () => { fired++; return {}; }, log: silentLog, timeoutMs: 1000 },
});
expect(out.engine).toBe('pattern');
expect(out.intent).toBe('deploy');
expect(out.appId).toBe('plex');
expect(fired).toBe(0);
});
test('credential store config → llm engine; off-catalog phrasing understood', async () => {
const calls = [];
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' }));
const fetchImpl = llmReplyFetch('{"intent":"deploy","appId":"jellyfin","reply":"Jellyfin it is"}', calls);
const out = await intentMod.classify({
message: 'I want a private YouTube for my living room TV',
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 5000 },
});
expect(out.engine).toBe('llm');
expect(out.intent).toBe('deploy');
expect(out.appId).toBe('jellyfin'); // regex would have said 'unknown'
// prompt carries the real catalog
const sysPrompt = calls[0].opts.body ? JSON.parse(calls[0].opts.body).messages[0].content : '';
expect(sysPrompt).toContain('jellyfin');
expect(sysPrompt).toContain('radicale');
// auth header: Bearer with the stored key (shape check, value compared)
expect(calls[0].opts.headers.Authorization).toBe(`Bearer ${LLM_TEST_KEY}`);
});
test('hallucinated template id is nulled (closed vocabulary)', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const out = await intentMod.classify({
message: 'deploy YouTube Premium please',
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"youtube-premium","reply":"ok"}'), log: silentLog, timeoutMs: 5000 },
});
expect(out.engine).toBe('llm');
expect(out.appId || null).toBeNull();
});
test('illegal intent string coerces to unknown', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const out = await intentMod.classify({
message: 'frobnicate the widget',
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"rm -rf","appId":null,"reply":"hmm"}'), log: silentLog, timeoutMs: 5000 },
});
expect(out.intent).toBe('unknown');
});
test('LLM HTTP 500 → pattern failover with llmError recorded', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const fetchImpl = async () => ({ ok: false, status: 500, json: async () => ({}) });
const out = await intentMod.classify({
message: 'why is plex down',
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 1000 },
});
expect(out.engine).toBe('pattern');
expect(out.intent).toBe('diagnose');
expect(out.serviceId).toBe('plex');
expect(out.llmError).toBeTruthy();
});
test('LLM timeout → AbortError surfaces as timeout failover', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const fetchImpl = (url, opts) => new Promise((_res, rej) => {
opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; rej(e); });
});
const out = await intentMod.classify({
message: 'Back up everything',
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 80 },
});
expect(out.engine).toBe('pattern');
expect(out.intent).toBe('backup');
expect(String(out.llmError)).toMatch(/timeout/i);
}, 10000);
test('markdown-fenced JSON + prefix prose is repaired and parsed', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const out = await intentMod.classify({
message: 'Block ads on my network',
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('Sure! ```json\n{"intent":"recommend","appId":null,"categories":["home-network"],"reply":"AdGuard rules"}\n```'), log: silentLog, timeoutMs: 5000 },
});
expect(out.engine).toBe('llm');
expect(out.intent).toBe('recommend');
expect(out.llmReply).toBe('AdGuard rules');
});
test('reply control chars stripped and bounded', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const long = 'x'.repeat(2000) + '\u0000evil';
const out = await intentMod.classify({
message: 'what is deployed',
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch(JSON.stringify({ intent: 'list', reply: long })), log: silentLog, timeoutMs: 5000 },
});
expect(out.llmReply.length).toBeLessThanOrEqual(600);
expect(out.llmReply.indexOf('evil')).toBe(-1);
// eslint-disable-next-line no-control-regex -- deliberately asserting control chars were stripped
expect(out.llmReply).not.toMatch(new RegExp('[\\u0000-\\u001F]'));
});
test('history is role-filtered and capped at MAX_HISTORY', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const calls = [];
const fetchImpl = llmReplyFetch('{"intent":"deploy","appId":"gitea","reply":"ok"}', calls);
const hist = Array.from({ length: 20 }, (_, i) => ({ role: i % 3 === 2 ? 'system' : (i % 2 ? 'user' : 'assistant'), content: 'c' + i }));
await intentMod.classify({ message: 'yes deploy it', history: hist, deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 5000 } });
const msgs = JSON.parse(calls[0].opts.body).messages;
expect(msgs.length).toBeLessThanOrEqual(8); // system + ≤6 history + user
expect(msgs.filter(m => m.role === 'system').length).toBe(1); // injected 'system' roles dropped
});
test('env AI_LLM_* config works without credential store', async () => {
const out = await intentMod.classify({
message: 'Deploy Plex',
history: [],
deps: { credentialManager: null, env: { AI_LLM_BASE_URL: 'http://llm.test/v1', AI_LLM_API_KEY: '***' + 'envkey', AI_LLM_MODEL: 'env-model' }, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"uptime-kuma","reply":"sure"}'), log: silentLog, timeoutMs: 5000 },
});
expect(out.engine).toBe('llm');
expect(out.model).toBe('env-model');
expect(out.appId).toBe('uptime-kuma'); // NOT in the old regex list — catalog-wide
});
test('LLM disagreement: pattern said recommend, LLM says deploy → deploy response rebuilt', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const out = await intentMod.classify({
message: 'I want to stream movies', // regex: recommend
history: [],
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"jellyfin","reply":"Jellyfin streams movies"}'), log: silentLog, timeoutMs: 5000 },
});
expect(out.intent).toBe('deploy');
expect(out.appId).toBe('jellyfin');
const enriched = intentMod.enrich({ ...out });
expect(enriched.response.message).toBeTruthy();
expect(enriched.response.appId).toBe('jellyfin');
expect(enriched.deployPlan.templateId).toBe('jellyfin');
expect(enriched.deployPlan.body.port).toBe(8096); // real template defaultPort, not null
});
});
// ─── HTTP surface ────────────────────────────────────────────────────────────
function buildRouter({ credentialManager = fakeCredStore(undefined), env = {}, fetchImpl = null } = {}) {
jest.resetModules();
const mod = require('../../routes/ai-intent');
const asyncHandler = (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
};
const errorResponse = (res, code, msg, extra) => res.status(code).json({ success: false, error: msg, ...(extra || {}) });
const app = express();
app.use(express.json());
app.use('/api/v1', mod({ asyncHandler, errorResponse, log: silentLog, siteConfig: null, credentialManager, fetchImpl, env }));
app.use((err, req, res, next) => { res.status(err.statusCode || 500).json({ success: false, error: err.message }); }); // eslint-disable-line no-unused-vars
return app;
}
async function start(app) {
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
});
return { server, base: `http://127.0.0.1:${server.address().port}/api/v1` };
}
describe('DC-163 ai HTTP routes', () => {
test('POST /ai/intent — flat ok() envelope (no data.data wrapper)', async () => {
const app = buildRouter();
const { server, base } = await start(app);
try {
const res = await fetch(`${base}/ai/intent`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Deploy Plex' }),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.intent).toBe('deploy');
expect(body.engine).toBe('pattern');
expect(body.response.message).toBeTruthy();
expect(body.response.appId).toBe('plex');
expect(body.deployPlan.body.name).toBe('Plex');
expect(body.timestamp).toBeTruthy();
} finally { server.close(); }
});
test('POST /ai/intent — 400 on missing/non-string message', async () => {
const app = buildRouter();
const { server, base } = await start(app);
try {
for (const bad of [{}, { message: '' }, { message: 42 }, { message: ' '}]) {
const res = await fetch(`${base}/ai/intent`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(bad) });
expect(res.status).toBe(400);
}
} finally { server.close(); }
});
test('POST /ai/intent — message over 1000 chars is truncated, not rejected', async () => {
const app = buildRouter();
const { server, base } = await start(app);
try {
const res = await fetch(`${base}/ai/intent`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Deploy Plex ' + 'z'.repeat(1500) }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.message.length).toBeLessThanOrEqual(1000);
expect(body.intent).toBe('deploy');
} finally { server.close(); }
});
test('GET /ai/settings — reports configuration WITHOUT the api key', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' }));
const app = buildRouter({ credentialManager: cm });
const { server, base } = await start(app);
try {
const res = await fetch(`${base}/ai/settings`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.configured).toBe(true);
expect(body.source).toBe('credential-store');
expect(body.model).toBe('test-model');
const raw = JSON.stringify(body);
expect(raw).not.toContain(LLM_TEST_KEY);
expect(body.hasApiKey).toBe(true);
} finally { server.close(); }
});
test('PUT /ai/settings — stores in credential store; requires baseUrl+apiKey on first save', async () => {
const cm = fakeCredStore(undefined);
const app = buildRouter({ credentialManager: cm });
const { server, base } = await start(app);
try {
// missing key → 400
const r1 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'http://llm.test/v1' }) });
expect(r1.status).toBe(400);
// bad baseUrl → 400
const r2 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'ftp://x', apiKey: LLM_TEST_KEY }) });
expect(r2.status).toBe(400);
// valid → stored encrypted (credentialManager.store), response never echoes key
const r3 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'http://llm.test/v1', model: 'test-model', apiKey: LLM_TEST_KEY }) });
const b3 = await r3.json();
expect(r3.status).toBe(200);
expect(b3.saved).toBe(true);
expect(JSON.stringify(b3)).not.toContain(LLM_TEST_KEY);
const stored = JSON.parse(cm._store.get('ai:llm'));
expect(stored.apiKey).toBe(LLM_TEST_KEY);
// partial update keeps stored key
const r4 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'new-model' }) });
expect(r4.status).toBe(200);
expect(JSON.parse(cm._store.get('ai:llm')).apiKey).toBe(LLM_TEST_KEY);
expect(JSON.parse(cm._store.get('ai:llm')).model).toBe('new-model');
} finally { server.close(); }
});
test('DELETE /ai/settings — clears stored config', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const app = buildRouter({ credentialManager: cm });
const { server, base } = await start(app);
try {
const res = await fetch(`${base}/ai/settings`, { method: 'DELETE' });
expect(res.status).toBe(200);
expect(cm._store.has('ai:llm')).toBe(false);
const after = await (await fetch(`${base}/ai/settings`)).json();
expect(after.configured).toBe(false);
} finally { server.close(); }
});
test('POST /ai/settings/test — end-to-end probe returns sample intent, no key', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' }));
const fetchImpl = llmReplyFetch('{"intent":"backup","appId":null,"reply":"Backup it is"}');
const app = buildRouter({ credentialManager: cm, fetchImpl });
const { server, base } = await start(app);
try {
const res = await fetch(`${base}/ai/settings/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.engine).toBe('llm');
expect(body.sample.intent).toBe('backup');
expect(JSON.stringify(body)).not.toContain(LLM_TEST_KEY);
} finally { server.close(); }
});
test('POST /ai/settings/test — failure degrades to ok:false with reason (never throws)', async () => {
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
const app = buildRouter({ credentialManager: cm, fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
const { server, base } = await start(app);
try {
const res = await fetch(`${base}/ai/settings/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.reason).toMatch(/ECONNREFUSED/);
} finally { server.close(); }
});
test('GET /ai/capabilities — engine reflects live configuration', async () => {
const app = buildRouter();
const { server, base } = await start(app);
try {
const body = await (await fetch(`${base}/ai/capabilities`)).json();
expect(body.success).toBe(true);
expect(body.engine).toBe('pattern');
expect(body.llm.configured).toBe(false);
expect(body.llm.howToEnable).toMatch(/credential store/);
expect(body.intents).toContain('deploy');
expect(body.capabilities.length).toBeGreaterThanOrEqual(6);
} finally { server.close(); }
});
});
+65
View File
@@ -7773,6 +7773,71 @@ paths:
'200':
description: Probe result (ok flag inside — transport errors are reported, not thrown)
/api/v1/ai/intent:
post:
tags: [AI]
summary: Natural language → structured intent
description: >
Dual-engine intent router. LLM engine (OpenAI-compatible classifier,
configured via /ai/settings or AI_LLM_* env) resolves against the
full app catalog and conversation history; pattern engine serves
offline and as automatic failover (engine field tells which answered;
llmError records the failover reason). Output is schema-validated
against closed vocabularies — the model can never emit an action or
template id outside the platform's real sets. Response is the flat
ok() envelope: {success, intent, confidence, action, engine, appId?,
serviceId?, response{message,appId?,appName?,recommendations?,
suggestions?,disclaimer?}, deployPlan?, wizardCall?, timestamp}.
responses:
'200':
description: Structured intent (flat envelope)
'400':
description: message (non-empty string) required
/api/v1/ai/capabilities:
get:
tags: [AI]
summary: What the assistant can do + live engine status
responses:
'200':
description: Intent list, capabilities, engine (llm|pattern), llm config status (key never included)
/api/v1/ai/settings:
get:
tags: [AI]
summary: LLM assistant configuration (key never echoed)
responses:
'200':
description: configured, source, baseUrl, model, hasApiKey
put:
tags: [AI]
summary: Store LLM config in the encrypted credential store
description: >
body {baseUrl?, model?, apiKey?}. Stored under credential key
'ai:llm' — NEVER config.json (GET /config returns the raw file to
the browser). Blank apiKey keeps the stored one. First save
requires baseUrl + apiKey. baseUrl must be http(s).
responses:
'200':
description: Saved (response never contains the key)
'400':
description: Invalid baseUrl or missing required fields
delete:
tags: [AI]
summary: Remove stored LLM config (falls back to env/pattern)
responses:
'200':
description: Cleared
/api/v1/ai/settings/test:
post:
tags: [AI]
summary: One-shot classifier probe (proves the LLM end-to-end)
description: Sends a fixed sample message through the configured LLM and returns the parsed intent. Failures answer ok:false with a reason (200), never throw.
responses:
'200':
description: '{ok, engine, model?, sample?{intent,reply}} or {ok:false, reason}'
/api/v1/mcp:
post:
tags: [MCP]
+420 -60
View File
@@ -5,18 +5,235 @@
* that can be executed against the DashCaddy API.
*
* POST /api/v1/ai/intent
* Body: { message: "I want to stream movies", context: {} }
* Returns: { intent, confidence, actions, followup }
* Body: { message: "I want to stream movies", history?: [{role,content}] }
* Returns: { intent, confidence, action, engine, response, appId?, … }
*
* The intent router uses pattern matching (not an LLM call) so it works
* instantly and offline. For complex queries, it can delegate to an
* external LLM via the LLM_PROXY_URL env var.
* Two engines, one contract:
* 1. LLM engine (default when configured): a small OpenAI-compatible model
* classifies the message into the DashCaddy intent vocabulary with the
* LIVE app catalog + wizard categories as the candidate list — so any
* of the 78 templates (and follow-ups like "name it vault") work,
* including ones the old regex list never knew about.
* Configured at PUT /api/v1/ai/settings (encrypted credential store,
* key 'ai:llm') with env fallbacks AI_LLM_* / LLM_PROXY_URL. NEVER
* config.json: GET /config returns the raw file to the browser.
* The upstream call uses the GLOBAL fetch with an AbortController —
* deliberately NOT ctx.fetchT, whose https path sets
* rejectUnauthorized:false for self-signed .sami certs; an EXTERNAL
* LLM API must get full TLS verification.
* 2. Pattern engine (instant, offline, zero-config): the original regex
* library. Used when no LLM is configured, and as the failover when the
* LLM call fails, times out, or returns an unrepairable shape.
* The response carries `engine` so callers can show what actually served.
*
* Injection-safety model (DC-163): the LLM is given data-finding tasks only.
* Output is schema-validated against closed vocabularies; free text is
* bounded and stripped of control chars. The model can NEVER produce an
* action string outside the known action set or a template id outside the
* catalog. The reply text is displayed client-side via textContent only.
* No LLM output is ever executed.
*/
'use strict';
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const { APP_TEMPLATES } = require('../src/docker/app-templates');
// ─── Intent Pattern Library ─────────────────────────────────────────────────
// ─── Vocabularies (closed sets — the LLM may only choose from these) ───────
const INTENTS = ['deploy', 'recommend', 'diagnose', 'health', 'list', 'backup', 'unknown'];
const ACTIONS_BY_INTENT = {
deploy: 'dashcaddy_deploy_app',
recommend: 'dashcaddy_wizard_recommend',
diagnose: 'dashcaddy_diagnose',
health: 'dashcaddy_system_health',
list: 'dashcaddy_list_services',
backup: 'dashcaddy_create_backup',
unknown: null,
};
const RECOMMEND_CATEGORIES = ['media-streaming', 'file-sync', 'home-network', 'smart-home', 'development', 'monitoring'];
const TEMPLATE_IDS = Object.keys(APP_TEMPLATES);
const MAX_INPUT = 1000; // user message cap (chars)
const MAX_REPLY = 600; // LLM reply cap (chars)
const MAX_HISTORY = 6; // prior turns sent to the LLM
const LLM_TIMEOUT_MS = 8000; // per-call deadline
// ─── Text sanitizer: strip control chars; no HTML filtering needed because
// the chat panel renders replies with textContent only. ─────────────────
function sanitizeText(s, max = MAX_REPLY) {
if (typeof s !== 'string') return '';
// eslint-disable-next-line no-control-regex
return s.replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
}
function normId(v) {
return typeof v === 'string' ? v.toLowerCase().replace(/[\s_]+/g, '-') : null;
}
// ─── Candidate catalog for the LLM prompt ──────────────────────────────────
function catalogBlock(limit = 80) {
const tpl = TEMPLATE_IDS.slice(0, limit).map((id) => {
const t = APP_TEMPLATES[id];
return `- ${id}: ${t.name || id} (${(t.description || '').split(/[.!]/)[0].slice(0, 90)})`;
}).join('\n');
const cats = RECOMMEND_CATEGORIES.map((c) => `- ${c}`).join('\n');
return { tpl, cats };
}
function buildSystemPrompt() {
const { tpl, cats } = catalogBlock();
return 'You are the intent classifier for DashCaddy, a self-hosting dashboard. ' +
'Read the user message (and conversation history) and output ONLY a minified JSON object, no markdown:\n' +
'{"intent":"deploy|recommend|diagnose|health|list|backup|unknown",' +
'"appId":"<template id or null>","serviceId":"<service name or null>",' +
'"categories":["<wizard categories>"],"reply":"<short friendly sentence>"}\n' +
'Rules:\n' +
'- intent=deploy when the user wants to install/host/launch/run a specific app (including "set it up", "yes go"). Use history to resolve pronouns.\n' +
'- appId MUST be one of the template ids below (exact id), else null. Never invent ids.\n' +
'- intent=recommend when the user states a goal without naming an app; categories from the wizard list.\n' +
'- diagnose for problems ("why is X down"), health for status questions, list for inventory questions, backup for backup requests, unknown otherwise.\n' +
'- reply: at most 160 characters, plain text, no markdown, no links.\n' +
'Template ids:\n' + tpl + '\nWizard categories:\n' + cats;
}
// ─── OpenAI-compatible chat call ───────────────────────────────────────────
async function callLlm(deps, cfg, messages) {
const doFetch = deps.fetchImpl || fetch;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), deps.timeoutMs || LLM_TIMEOUT_MS);
try {
const res = await doFetch(`${cfg.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${cfg.apiKey}`,
},
body: JSON.stringify({
model: cfg.model,
messages,
temperature: 0,
max_tokens: 300,
}),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`LLM HTTP ${res.status}`);
}
const data = await res.json();
const content = data && data.choices && data.choices[0] &&
data.choices[0].message && data.choices[0].message.content;
if (typeof content !== 'string' || !content.trim()) {
throw new Error('LLM empty completion');
}
return content;
} finally {
clearTimeout(timer);
}
}
// ─── Parse + repair + schema-validate the LLM output ───────────────────────
function repairParsed(raw) {
// Deterministic corrections for common small-model drift BEFORE schema.
let out = raw.replace(/```(json)?/gi, '').trim();
const first = out.indexOf('{');
const last = out.lastIndexOf('}');
if (first !== -1 && last > first) out = out.slice(first, last + 1);
return out;
}
function validateParsed(parsed) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
let intent = typeof parsed.intent === 'string' ? parsed.intent.toLowerCase() : null;
if (!INTENTS.includes(intent)) {
// drift repair: unknown-ish values → 'unknown'
intent = 'unknown';
}
const appId = normId(parsed.appId);
const serviceId = normId(parsed.serviceId);
const categories = Array.isArray(parsed.categories)
? parsed.categories.map((c) => (typeof c === 'string' ? c.toLowerCase() : null)).filter((c) => c && RECOMMEND_CATEGORIES.includes(c))
: [];
return {
intent,
appId: appId && TEMPLATE_IDS.includes(appId) ? appId : null,
serviceId: serviceId || null,
categories,
reply: sanitizeText(parsed.reply, MAX_REPLY),
};
}
// ─── Merge validated LLM fields onto the pattern result ────────────────────
// Pattern stays the structural base (known-good shapes); LLM contributes the
// semantic understanding. appId is never LOST silently when both engines
// disagree — the LLM candidate wins if it is a valid catalog id, because the
// LLM saw the full 78-app catalog while regex knows only its hardcoded list.
function mergeIntent(patternResult, llm) {
const merged = { ...patternResult };
const intentChanged = llm.intent !== patternResult.intent;
merged.intent = llm.intent;
merged.action = ACTIONS_BY_INTENT[llm.intent] || patternResult.action || null;
// If the LLM routed to a DIFFERENT intent than the pattern did, the
// pattern's canned response object belongs to the old intent — drop it
// and let enrich() rebuild the shape for the new one (with llmReply text).
if (intentChanged) merged.response = null;
if (llm.appId) merged.appId = llm.appId;
if (llm.serviceId && !merged.serviceId) merged.serviceId = llm.serviceId;
if (llm.intent === 'recommend') {
merged.categories = (llm.categories && llm.categories.length) ? llm.categories : (patternResult.categories || ['media-streaming']);
}
if (llm.reply) merged.llmReply = llm.reply;
return merged;
}
const DEFAULT_INTENT_MESSAGES = {
deploy: 'Ready to deploy.',
recommend: 'Here are some suggestions:',
diagnose: 'Let me check what\'s going on...',
health: 'Checking system health...',
list: 'Here are your services:',
backup: 'Creating a full system backup now...',
unknown: 'I\'m not sure what you\'d like to do — try the app catalog, or rephrase.',
};
// ─── Runtime config resolution ─────────────────────────────────────────────
// Priority: encrypted credential store (self-service Settings UI, key
// 'ai:llm') → env (AI_LLM_* / LLM_PROXY_URL). NEVER config.json: its GET
// endpoint returns the raw file to the browser, so secrets must not live
// there. Env fallback keeps headless installs working without the UI.
async function getLlmConfig(deps) {
const env = deps.env || {};
let stored = null;
if (deps.credentialManager) {
try {
const raw = await deps.credentialManager.retrieve('ai:llm');
if (raw) stored = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (_) { /* not stored yet — env fallback */ }
}
const baseUrl = ((stored && stored.baseUrl) || env.AI_LLM_BASE_URL || env.LLM_PROXY_URL || '').trim();
const apiKey = ((stored && stored.apiKey) || env.AI_LLM_API_KEY || env.LLM_PROXY_API_KEY || '').trim();
const model = ((stored && stored.model) || env.AI_LLM_MODEL || 'gpt-4o-mini').trim();
if (!baseUrl || !apiKey) return null;
if (!/^https?:\/\//i.test(baseUrl)) return null;
const source = (stored && stored.baseUrl && stored.apiKey) ? 'credential-store' : 'env';
return { baseUrl, apiKey, model, source };
}
// sync alias retained for pure-callers/tests that never touch the store
function resolveLlmConfig({ env = {}, site = null } = {}) {
const ai = (site && site.ai) || {};
const llm = ai.llm || {};
const baseUrl = (llm.baseUrl || env.AI_LLM_BASE_URL || env.LLM_PROXY_URL || '').trim();
const apiKey = (llm.apiKey || env.AI_LLM_API_KEY || env.LLM_PROXY_API_KEY || '').trim();
const model = (llm.model || env.AI_LLM_MODEL || 'gpt-4o-mini').trim();
if (!baseUrl || !apiKey) return null;
if (!/^https?:\/\//i.test(baseUrl)) return null;
return { baseUrl, apiKey, model };
}
// ─── Intent Pattern Library (engine 2 — offline fallback; unchanged shapes) ─
const INTENT_PATTERNS = [
// ── Deploy intents ──
@@ -142,7 +359,6 @@ const INTENT_PATTERNS = [
],
action: 'dashcaddy_diagnose',
extractService: (msg) => {
// Try to extract service name from "why is X down" patterns
const match = msg.match(/(?:why is |is |)(\w+)\s+(?:down|slow|broken|not working|failing|crashed)/i);
if (match) return match[1].toLowerCase();
return null;
@@ -193,12 +409,9 @@ const INTENT_PATTERNS = [
},
];
// ─── Intent Router ──────────────────────────────────────────────────────────
// ─── Pattern engine (pure, sync — the public routeIntent contract) ─────────
function routeIntent(message) {
const msg = message.toLowerCase().trim();
// Try each intent pattern
for (const intent of INTENT_PATTERNS) {
for (const pattern of intent.patterns) {
if (pattern.test(message)) {
@@ -210,19 +423,14 @@ function routeIntent(message) {
response: typeof intent.response === 'function' ? intent.response(message) : null,
};
// Extract app name for deploy intents
if (intent.extractApp) {
const app = intent.extractApp(message);
if (app) result.appId = app;
}
// Extract service name for diagnose intents
if (intent.extractService) {
const service = intent.extractService(message);
if (service) result.serviceId = service;
}
// Suggest categories for recommend intents
if (intent.suggestCategories) {
result.categories = intent.suggestCategories;
}
@@ -232,7 +440,6 @@ function routeIntent(message) {
}
}
// No match — return a fallback that suggests using the catalog
return {
intent: 'unknown',
confidence: 0.3,
@@ -251,81 +458,222 @@ function routeIntent(message) {
};
}
// ─── Enrichment shared by both engines (plans, wizard calls, reply merge) ──
function enrich(result) {
if (result.intent === 'deploy' && result.appId) {
const tpl = APP_TEMPLATES[result.appId];
result.appName = (tpl && tpl.name) || result.appId;
result.deployPlan = {
templateId: result.appId,
endpoint: 'POST /api/v1/discover/adopt',
body: {
containerId: null, // set after container creation
serviceId: result.appId,
name: result.appName,
port: (tpl && tpl.defaultPort) || null, // from template when known
generateDns: true,
generateRoute: true,
},
nextSteps: [
`Search catalog: GET /api/v1/catalog/search?q=${encodeURIComponent(result.appId)}`,
`Get template: GET /api/v1/catalog/${encodeURIComponent(result.appId)}`,
'Deploy: POST /api/v1/discover/adopt',
],
};
const baseMsg = result.response && result.response.message
? result.response.message
: (result.llmReply || DEFAULT_INTENT_MESSAGES.deploy);
result.response = { ...(result.response || {}), message: baseMsg, appId: result.appId, appName: result.appName };
} else if (result.intent === 'recommend' && result.categories) {
result.wizardCall = {
endpoint: 'POST /api/v1/wizard/recommend',
body: { categories: result.categories, hardwareProfile: 'medium' },
};
// pattern canned recs stay (they're curated); LLM reply rides along as
// llmReply for the UI's secondary line, and a message exists either way.
result.response = { ...(result.response || {}), message: (result.response && result.response.message) || result.llmReply || DEFAULT_INTENT_MESSAGES.recommend };
if (result.llmReply) result.response.llmReply = result.llmReply;
} else if (result.intent !== 'deploy') {
const baseMsg = (result.response && result.response.message)
? result.response.message
: (result.llmReply || DEFAULT_INTENT_MESSAGES[result.intent] || DEFAULT_INTENT_MESSAGES.unknown);
result.response = { ...(result.response || {}), message: baseMsg, suggestions: (result.response && result.response.suggestions) || undefined };
}
return result;
}
// ─── Classification orchestration ──────────────────────────────────────────
async function classify({ message, history, deps }) {
const pattern = routeIntent(message);
const cfg = await getLlmConfig(deps);
if (!cfg) {
return { ...pattern, engine: 'pattern' };
}
const safeHistory = (Array.isArray(history) ? history : [])
.filter((h) => h && (h.role === 'user' || h.role === 'assistant') && typeof h.content === 'string')
.slice(-MAX_HISTORY);
const messages = [
{ role: 'system', content: buildSystemPrompt() },
...safeHistory,
{ role: 'user', content: message },
];
try {
const content = await callLlm(deps, cfg, messages);
const parsed = JSON.parse(repairParsed(content));
const validated = validateParsed(parsed);
if (!validated) throw new Error('LLM output failed schema validation');
deps.log.info('ai-intent', 'llm classified', { intent: validated.intent, appId: validated.appId });
const merged = mergeIntent(pattern, validated);
return { ...merged, engine: 'llm', model: cfg.model };
} catch (e) {
const reason = (e && e.name === 'AbortError') ? 'timeout' : (e && e.message) || 'unknown';
deps.log.warn('ai-intent', 'llm unavailable — pattern fallback', { reason: String(reason).slice(0, 200) });
return { ...pattern, engine: 'pattern', llmError: String(reason).slice(0, 200) };
}
}
// ─── Express Route ──────────────────────────────────────────────────────────
module.exports = function({ asyncHandler }) {
module.exports = function ({ asyncHandler, siteConfig = null, log = console, fetchImpl = null, env = process.env, credentialManager = null }) {
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
const router = express.Router();
const deps = {
siteConfig,
credentialManager,
log: (log && log.info && log.warn) ? log : { info: () => {}, warn: () => {}, error: () => {} },
fetchImpl,
env,
timeoutMs: Number(env.AI_LLM_TIMEOUT_MS) || LLM_TIMEOUT_MS,
};
/**
* POST /api/v1/ai/intent
*
* Natural language → structured action plan
* POST /api/v1/ai/intent — natural language → structured action plan
*/
router.post('/ai/intent', wrap(async (req, res) => {
const { message, context = {} } = req.body || {};
let history = Array.isArray(req.body && req.body.history) ? req.body.history : [];
if (!message || typeof message !== 'string') {
if (!message || typeof message !== 'string' || !message.trim()) {
return errorResponse(res, 400, 'message (string) is required');
}
const cleanMessage = message.trim().slice(0, MAX_INPUT);
const result = routeIntent(message);
// History: keep only role/content string pairs from the client (the
// panel's own transcript), bounded and sanitized. Prompt-injection is
// contained by the closed-vocabulary schema, not by trusting this data.
history = history
.filter((h) => h && (h.role === 'user' || h.role === 'assistant') && typeof h.content === 'string')
.slice(-MAX_HISTORY)
.map((h) => ({ role: h.role, content: sanitizeText(h.content, 400) }));
// Add context from the request
const result = await classify({ message: cleanMessage, history, deps });
result.context = context;
result.timestamp = new Date().toISOString();
// For deploy intents with an appId, include the deploy plan
if (result.intent === 'deploy' && result.appId) {
result.deployPlan = {
templateId: result.appId,
endpoint: 'POST /api/v1/discover/adopt',
body: {
containerId: null, // Will be set after container creation
serviceId: result.appId,
name: result.appId.charAt(0).toUpperCase() + result.appId.slice(1),
port: null, // Will be set from template
generateDns: true,
generateRoute: true,
},
nextSteps: [
`Search catalog: GET /api/v1/catalog/search?q=${result.appId}`,
`Get template: GET /api/v1/catalog/${result.appId}`,
`Deploy: POST /api/v1/discover/adopt`,
],
};
}
// For recommend intents, include the wizard endpoint
if (result.intent === 'recommend' && result.categories) {
result.wizardCall = {
endpoint: 'POST /api/v1/wizard/recommend',
body: { categories: result.categories, hardwareProfile: 'medium' },
};
}
enrich(result);
ok(res, result);
}));
/**
* GET /api/v1/ai/capabilities
* Returns what the AI can do — useful for agent self-discovery
* GET /api/v1/ai/settings — LLM assistant configuration (key never echoed)
*/
router.get('/ai/settings', wrap(async (req, res) => {
const cfg = await getLlmConfig(deps);
ok(res, {
configured: Boolean(cfg),
source: cfg ? cfg.source : null,
baseUrl: cfg ? cfg.baseUrl : null,
model: cfg ? cfg.model : null,
hasApiKey: Boolean(cfg && cfg.apiKey),
});
}));
/**
* PUT /api/v1/ai/settings — store LLM assistant config in the ENCRYPTED
* credential store (key 'ai:llm'). Never config.json — GET /config returns
* the raw file to the browser. body: { baseUrl?, model?, apiKey? }; an
* empty apiKey string means "keep the stored one".
*/
router.put('/ai/settings', wrap(async (req, res) => {
if (!deps.credentialManager) {
return errorResponse(res, 503, 'Credential store unavailable — set AI_LLM_BASE_URL/AI_LLM_API_KEY env vars instead');
}
const { baseUrl = '', model = '', apiKey = '' } = req.body || {};
if (baseUrl && !/^https?:\/\//i.test(String(baseUrl).trim())) {
return errorResponse(res, 400, 'baseUrl must be an http(s) URL of an OpenAI-compatible endpoint');
}
let stored = null;
try {
const raw = await deps.credentialManager.retrieve('ai:llm');
if (raw) stored = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (_) { /* fresh config */ }
stored = stored || {};
if (baseUrl) stored.baseUrl = String(baseUrl).trim();
if (model) stored.model = String(model).trim();
if (String(apiKey).trim()) stored.apiKey = String(apiKey).trim();
if (!stored.baseUrl || !stored.apiKey) {
return errorResponse(res, 400, 'baseUrl and apiKey are both required to enable the LLM engine (or clear with DELETE)');
}
await deps.credentialManager.store('ai:llm', JSON.stringify(stored), { label: 'AI assistant LLM config' });
log.info('ai-intent', 'LLM config saved', { model: stored.model });
ok(res, { saved: true, baseUrl: stored.baseUrl, model: stored.model, hasApiKey: true });
}));
/**
* DELETE /api/v1/ai/settings — remove stored config (falls back to env/pattern)
*/
router.delete('/ai/settings', wrap(async (req, res) => {
if (!deps.credentialManager) return errorResponse(res, 503, 'Credential store unavailable');
try { await deps.credentialManager.delete('ai:llm'); } catch (_) { /* already gone */ }
ok(res, { cleared: true });
}));
/**
* POST /api/v1/ai/settings/test — one-shot classify probe proving the LLM
* works end-to-end (returns engine + sample intent, never the key).
*/
router.post('/ai/settings/test', wrap(async (req, res) => {
const cfg = await getLlmConfig(deps);
if (!cfg) return ok(res, { ok: false, engine: 'pattern', reason: 'No LLM configured (store or env)' });
try {
const content = await callLlm(deps, cfg, [
{ role: 'system', content: buildSystemPrompt() },
{ role: 'user', content: 'Back up everything please' },
]);
const parsed = JSON.parse(repairParsed(content));
const validated = validateParsed(parsed);
if (!validated || !validated.intent) throw new Error('invalid classifier output');
return ok(res, { ok: true, engine: 'llm', model: cfg.model, sample: { intent: validated.intent, reply: validated.reply } });
} catch (e) {
const reason = String((e && (e.name === 'AbortError' ? 'timeout' : e.message)) || 'error').slice(0, 200);
log.warn('ai-intent', 'LLM config test failed', { reason });
return ok(res, { ok: false, engine: 'llm-error', reason });
}
}));
/**
* GET /api/v1/ai/capabilities — what the AI can do + which engine is live
*/
router.get('/ai/capabilities', wrap(async (req, res) => {
const cfg = await getLlmConfig(deps);
ok(res, {
intents: [...new Set(INTENT_PATTERNS.map(p => p.intent))],
intents: INTENTS.filter((i) => i !== 'unknown'),
capabilities: [
{ name: 'deploy', description: 'Deploy self-hosted applications from the catalog' },
{ name: 'deploy', description: 'Deploy self-hosted applications from the catalog (all 78 templates)' },
{ name: 'recommend', description: 'Get service recommendations based on goals' },
{ name: 'diagnose', description: 'Troubleshoot service issues' },
{ name: 'backup', description: 'Create full system backups' },
{ name: 'health', description: 'Check system and service health' },
{ name: 'list', description: 'List services and containers' },
],
tools: '17 MCP tools available via MCP protocol at src/mcp/mcp-server.js',
tools: '17 MCP tools available via MCP protocol at /api/v1/mcp',
engine: cfg ? 'llm' : 'pattern',
llm: cfg ? { model: cfg.model, configured: true, source: cfg.source } : { configured: false, howToEnable: 'Dashboard → AI Assistant panel → ⚙ Settings (stored in the encrypted credential store), or env AI_LLM_BASE_URL + AI_LLM_API_KEY + AI_LLM_MODEL' },
exampleQueries: [
'Deploy Plex',
'I want to stream movies',
'I want a private YouTube for my TV',
'Block ads on my network',
'Why is Plex down?',
'Back up everything',
@@ -337,4 +685,16 @@ module.exports = function({ asyncHandler }) {
return router;
};
module.exports.routeIntent = routeIntent;
// ─── Pure exports (unit-test surface) ───────────────────────────────────────
module.exports.routeIntent = routeIntent;
module.exports.classify = classify;
module.exports.enrich = enrich;
module.exports.mergeIntent = mergeIntent;
module.exports.validateParsed = validateParsed;
module.exports.repairParsed = repairParsed;
module.exports.buildSystemPrompt = buildSystemPrompt;
module.exports.sanitizeText = sanitizeText;
module.exports.resolveLlmConfig = resolveLlmConfig;
module.exports.INTENTS = INTENTS;
module.exports.TEMPLATE_IDS = TEMPLATE_IDS;
module.exports.MAX_INPUT = MAX_INPUT;
+1 -1
View File
@@ -1022,7 +1022,7 @@ async function createApp() {
// Log Insights — plain English activity summary + safe log disposal
apiRouter.use('/disk-settings', diskSettingsRoutes);
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler }));
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler, siteConfig: ctx.siteConfig, log: ctx.log, credentialManager: ctx.credentialManager }));
apiRouter.use(logInsightsRoutes({
asyncHandler: ctx.asyncHandler,
ok: ctx.ok,
+146 -146
View File
@@ -90,15 +90,15 @@
<button id="logo-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);const x=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),R=document.getElementById("logo-preview-light"),h=document.getElementById("logo-status"),P=document.getElementById("logo-same-both"),A=document.getElementById("logo-dual-uploads"),D=document.getElementById("logo-single-upload"),j=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),$=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),T=document.querySelector(".top-row"),z=document.getElementById("dashboard-title"),C=DC.NAME;let H=null,b=null,d=null,v="left",S=C;P?.addEventListener("change",()=>{P.checked?(A.style.display="none",D.style.display="",H=null,b=null):(A.style.display="flex",D.style.display="none",d=null)});function O(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const s=new FileReader;s.onload=n=>e(n.target.result),s.readAsDataURL(t)}j?.addEventListener("change",t=>{O(t.target.files[0],e=>{H=e,E.src=e,h.textContent="New dark logo ready to save"})}),g?.addEventListener("change",t=>{O(t.target.files[0],e=>{b=e,R.src=e,h.textContent="New light logo ready to save"})}),$?.addEventListener("change",t=>{O(t.target.files[0],e=>{d=e,E.src=e,R.src=e,h.textContent="New logo ready to save (both themes)"})});function m(t){T.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 f(t){S=t||C,document.title=S;const e=document.querySelector(".dashboard-title");e&&(e.textContent=S)}async function B(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(M.src=e.customLogoLight,R.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,M.src=e.customLogo,E.src=e.customLogo,R.src=e.customLogo),e.isDefault||(h.textContent="Using custom logo"),e.position&&(v=e.position,m(e.position)),e.dashboardTitle&&f(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{v=t.dataset.pos,m(v)})}),document.getElementById("brand")?.addEventListener("click",()=>{H=null,b=null,d=null,j&&(j.value=""),g&&(g.value=""),$&&($.value=""),P&&(P.checked=!1),A.style.display="flex",D.style.display="none",E.src=k.src,R.src=M.src;const t=k.src.includes("custom-logo")||M.src.includes("custom-logo");h.textContent=t?"Using custom logo":"Using default logos",m(v),z.value=S,x.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=z.value.trim()||C,e={position:v,dashboardTitle:t};P?.checked&&d?(e.dataDark=d,e.dataLight=d):(H&&(e.dataDark=H),b&&(e.dataLight=b));const s=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(s.ok){const n=await s.json(),r="?t="+Date.now();n.pathDark&&(k.src=n.pathDark+r,E.src=n.pathDark+r),n.pathLight&&(M.src=n.pathLight+r,R.src=n.pathLight+r),m(v),f(t),x.classList.remove("show")}else{const n=await s.json();showNotification("Failed to save: "+n.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
</div>`);const x=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),j=document.getElementById("logo-preview-light"),h=document.getElementById("logo-status"),P=document.getElementById("logo-same-both"),A=document.getElementById("logo-dual-uploads"),D=document.getElementById("logo-single-upload"),R=document.getElementById("logo-upload-dark"),y=document.getElementById("logo-upload-light"),$=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),T=document.querySelector(".top-row"),z=document.getElementById("dashboard-title"),C=DC.NAME;let M=null,b=null,d=null,v="left",S=C;P?.addEventListener("change",()=>{P.checked?(A.style.display="none",D.style.display="",M=null,b=null):(A.style.display="flex",D.style.display="none",d=null)});function O(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const s=new FileReader;s.onload=n=>e(n.target.result),s.readAsDataURL(t)}R?.addEventListener("change",t=>{O(t.target.files[0],e=>{M=e,E.src=e,h.textContent="New dark logo ready to save"})}),y?.addEventListener("change",t=>{O(t.target.files[0],e=>{b=e,j.src=e,h.textContent="New light logo ready to save"})}),$?.addEventListener("change",t=>{O(t.target.files[0],e=>{d=e,E.src=e,j.src=e,h.textContent="New logo ready to save (both themes)"})});function m(t){T.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 f(t){S=t||C,document.title=S;const e=document.querySelector(".dashboard-title");e&&(e.textContent=S)}async function B(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,j.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,H.src=e.customLogo,E.src=e.customLogo,j.src=e.customLogo),e.isDefault||(h.textContent="Using custom logo"),e.position&&(v=e.position,m(e.position)),e.dashboardTitle&&f(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{v=t.dataset.pos,m(v)})}),document.getElementById("brand")?.addEventListener("click",()=>{M=null,b=null,d=null,R&&(R.value=""),y&&(y.value=""),$&&($.value=""),P&&(P.checked=!1),A.style.display="flex",D.style.display="none",E.src=k.src,j.src=H.src;const t=k.src.includes("custom-logo")||H.src.includes("custom-logo");h.textContent=t?"Using custom logo":"Using default logos",m(v),z.value=S,x.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=z.value.trim()||C,e={position:v,dashboardTitle:t};P?.checked&&d?(e.dataDark=d,e.dataLight=d):(M&&(e.dataDark=M),b&&(e.dataLight=b));const s=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(s.ok){const n=await s.json(),r="?t="+Date.now();n.pathDark&&(k.src=n.pathDark+r,E.src=n.pathDark+r),n.pathLight&&(H.src=n.pathLight+r,j.src=n.pathLight+r),m(v),f(t),x.classList.remove("show")}else{const n=await s.json();showNotification("Failed to save: "+n.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&&(k.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",R.src="/assets/dashcaddy-logo-light.png",h.textContent="Using default logos",H=null,b=null,d=null,z.value=C,f(C),v="left",m("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const s=document.querySelector('link[rel="icon"]'),n=document.getElementById("favicon-preview"),r=document.getElementById("favicon-status");s&&(s.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),n&&(n.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),r&&(r.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(x,document.getElementById("logo-cancel"));const w=document.getElementById("favicon-preview"),L=document.getElementById("favicon-status"),o=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function y(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(p.href=e.customFavicon+"?t="+Date.now(),w.src=e.customFavicon+"?t="+Date.now(),L.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}o?.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"),o.value="";return}const s=new FileReader;s.onload=n=>{l=n.target.result,w.src=l,L.textContent="New favicon ready to save"},s.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();p.href=e.path+"?t="+Date.now(),w.src=e.path+"?t="+Date.now(),L.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),y(),B();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{x.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const s=await fetch("/api/v1/config");s.ok&&(e=(await s.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(x,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const s=await fetch("/api/v1/config");if(!s.ok)return;const n=await s.json();n.timezone=e,n.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})}catch(s){console.warn("Failed to save timezone:",s.message)}}))})(),window.populateTimezoneSelect=function(x,E){const R=Intl.supportedValuesOf("timeZone"),h=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";x.innerHTML="";for(const P of R){const A=document.createElement("option");A.value=P,A.textContent=P.replace(/_/g," "),P===h&&(A.selected=!0),x.appendChild(A)}},(function(){let x="homelab",E=null;async function R(){try{const f=await fetch("/api/v1/config");if(f.ok&&(E=await f.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(f){console.warn("Could not fetch server config, checking localStorage fallback:",f.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}R();const h=document.getElementById("setup-timezone");h&&window.populateTimezoneSelect(h);function P(m){document.querySelectorAll(".setup-step").forEach(B=>{B.style.display="none"});const f=document.getElementById(m);f&&(f.style.display="block")}function A(){const m=document.getElementById("setup-summary-content");if(!m)return;let f='<div style="display: grid; gap: 20px;">';if(x==="homelab"){const w=document.getElementById("setup-tld")?.value?.trim()||".home",L=document.getElementById("setup-ca-name")?.value?.trim()||"",o=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;f+=`
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(k.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",h.textContent="Using default logos",M=null,b=null,d=null,z.value=C,f(C),v="left",m("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const s=document.querySelector('link[rel="icon"]'),n=document.getElementById("favicon-preview"),r=document.getElementById("favicon-status");s&&(s.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),n&&(n.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),r&&(r.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(x,document.getElementById("logo-cancel"));const w=document.getElementById("favicon-preview"),L=document.getElementById("favicon-status"),o=document.getElementById("favicon-upload"),u=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(u.rel="icon",u.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(u));async function g(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(u.href=e.customFavicon+"?t="+Date.now(),w.src=e.customFavicon+"?t="+Date.now(),L.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}o?.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"),o.value="";return}const s=new FileReader;s.onload=n=>{l=n.target.result,w.src=l,L.textContent="New favicon ready to save"},s.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();u.href=e.path+"?t="+Date.now(),w.src=e.path+"?t="+Date.now(),L.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),g(),B();const p=document.getElementById("settings-timezone");p&&(new MutationObserver(()=>{x.classList.contains("show")&&p.options.length===0&&(async()=>{let e;try{const s=await fetch("/api/v1/config");s.ok&&(e=(await s.json()).timezone)}catch{}window.populateTimezoneSelect(p,e)})()}).observe(x,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=p.value;if(e)try{const s=await fetch("/api/v1/config");if(!s.ok)return;const n=await s.json();n.timezone=e,n.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})}catch(s){console.warn("Failed to save timezone:",s.message)}}))})(),window.populateTimezoneSelect=function(x,E){const j=Intl.supportedValuesOf("timeZone"),h=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";x.innerHTML="";for(const P of j){const A=document.createElement("option");A.value=P,A.textContent=P.replace(/_/g," "),P===h&&(A.selected=!0),x.appendChild(A)}},(function(){let x="homelab",E=null;async function j(){try{const f=await fetch("/api/v1/config");if(f.ok&&(E=await f.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(f){console.warn("Could not fetch server config, checking localStorage fallback:",f.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}j();const h=document.getElementById("setup-timezone");h&&window.populateTimezoneSelect(h);function P(m){document.querySelectorAll(".setup-step").forEach(B=>{B.style.display="none"});const f=document.getElementById(m);f&&(f.style.display="block")}function A(){const m=document.getElementById("setup-summary-content");if(!m)return;let f='<div style="display: grid; gap: 20px;">';if(x==="homelab"){const w=document.getElementById("setup-tld")?.value?.trim()||".home",L=document.getElementById("setup-ca-name")?.value?.trim()||"",o=document.getElementById("setup-dns-ip")?.value?.trim()||"",u=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;f+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${w}</div>
<div><strong>Certificate Authority:</strong> ${L}</div>
<div><strong>DNS Server:</strong> ${o}:${p}</div>
<div><strong>DNS Server:</strong> ${o}:${u}</div>
<div><strong>Example URLs:</strong> https://uptime${w}, https://nextcloud${w}</div>
</div>
</div>
@@ -112,7 +112,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
<div><strong>Example URLs:</strong> http://${w}:8080, http://${w}:3000</div>
</div>
</div>
`}else if(x==="public"){const w=document.getElementById("setup-public-domain")?.value?.trim()||"",L=document.getElementById("setup-public-email")?.value?.trim()||"",o=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=o==="subdirectory"?`https://${w}/sonarr, https://${w}/grafana`:`https://sonarr.${w}, https://grafana.${w}`;f+=`
`}else if(x==="public"){const w=document.getElementById("setup-public-domain")?.value?.trim()||"",L=document.getElementById("setup-public-email")?.value?.trim()||"",o=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u=o==="subdirectory"?`https://${w}/sonarr, https://${w}/grafana`:`https://sonarr.${w}, https://grafana.${w}`;f+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
@@ -120,14 +120,14 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
<div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${L}</div>
<div><strong>Routing:</strong> ${o==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${p}</div>
<div><strong>Example URLs:</strong> ${u}</div>
</div>
</div>
`}const B=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${B.replace(/_/g," ")}</div>
</div>
`,f+="</div>",m.innerHTML=f,P("setup-step-summary")}async function D(m){try{const f=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});return f.ok?(await f.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${f.status}`),{function:"saveConfigToServer"}),!1)}catch(f){return errorHandler.logError("[SetupWizard] Save Config",f,{function:"saveConfigToServer"}),!1}}async function j(){const m={setupComplete:!0,configurationType:x,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(x==="homelab"){m.tld=document.getElementById("setup-tld")?.value?.trim()||".home",m.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const L=document.getElementById("setup-dns-provider")?.value||"technitium";m.dns={provider:L,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},m.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else x==="simple"?(m.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",m.defaults={dnsType:"none",sslType:"none",targetIP:m.defaultIP}):x==="public"&&(m.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",m.email=document.getElementById("setup-public-email")?.value?.trim()||"",m.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m.defaults={dnsType:m.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const f=await D(m);safeSet("dashcaddy-config",JSON.stringify(m)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const B=x==="homelab"?"Professional Home Lab":x==="simple"?"Simple Setup":"Public Server",w=f?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${B}. Settings saved to: ${w}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(m){m.preventDefault();const f=document.querySelector('input[name="config-type"]:checked');f&&(x=f.value),P(x==="homelab"?"setup-step-homelab":x==="simple"?"setup-step-simple":x==="public"?"setup-step-public":"setup-step-homelab")});const $=document.getElementById("setup-skip");$&&($.onclick=async function(m){m.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await D({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(m){const f=m.target.value||".home",B=document.getElementById("tld-preview"),w=document.getElementById("tld-preview-2");B&&(B.textContent=f),w&&(w.textContent=f)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(m){m.preventDefault(),P("setup-step-1")});const T=document.getElementById("setup-homelab-next");T&&(T.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-tld")?.value?.trim()||"",B=document.getElementById("setup-ca-name")?.value?.trim()||"",w=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!f||!f.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!B){showNotification("Please enter a Certificate Authority name","warning");return}if(!w){showNotification("Please enter your DNS server IP address","warning");return}A()});const z=document.getElementById("setup-simple-back");z&&(z.onclick=function(m){m.preventDefault(),P("setup-step-1")});const C=document.getElementById("setup-simple-next");C&&(C.onclick=function(m){m.preventDefault(),A()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(m){m.onchange=function(){var f=document.getElementById("dns-requirement-note");f&&(f.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const H=document.getElementById("setup-public-back");H&&(H.onclick=function(m){m.preventDefault(),P("setup-step-1")});const b=document.getElementById("setup-public-next");b&&(b.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-public-domain")?.value?.trim()||"",B=document.getElementById("setup-public-email")?.value?.trim()||"";if(!f){showNotification("Please enter your domain name","warning");return}if(!B||!B.includes("@")){showNotification("Please enter a valid email address","warning");return}A()});const d=document.getElementById("setup-summary-back");d&&(d.onclick=function(m){m.preventDefault(),x==="homelab"?P("setup-step-homela Line truncated
`,f+="</div>",m.innerHTML=f,P("setup-step-summary")}async function D(m){try{const f=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});return f.ok?(await f.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${f.status}`),{function:"saveConfigToServer"}),!1)}catch(f){return errorHandler.logError("[SetupWizard] Save Config",f,{function:"saveConfigToServer"}),!1}}async function R(){const m={setupComplete:!0,configurationType:x,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(x==="homelab"){m.tld=document.getElementById("setup-tld")?.value?.trim()||".home",m.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const L=document.getElementById("setup-dns-provider")?.value||"technitium";m.dns={provider:L,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},m.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else x==="simple"?(m.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",m.defaults={dnsType:"none",sslType:"none",targetIP:m.defaultIP}):x==="public"&&(m.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",m.email=document.getElementById("setup-public-email")?.value?.trim()||"",m.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m.defaults={dnsType:m.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const f=await D(m);safeSet("dashcaddy-config",JSON.stringify(m)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const B=x==="homelab"?"Professional Home Lab":x==="simple"?"Simple Setup":"Public Server",w=f?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${B}. Settings saved to: ${w}`,"success",5e3),setTimeout(()=>location.reload(),500)}const y=document.getElementById("setup-step-1-next");y&&(y.onclick=function(m){m.preventDefault();const f=document.querySelector('input[name="config-type"]:checked');f&&(x=f.value),P(x==="homelab"?"setup-step-homelab":x==="simple"?"setup-step-simple":x==="public"?"setup-step-public":"setup-step-homelab")});const $=document.getElementById("setup-skip");$&&($.onclick=async function(m){m.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await D({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(m){const f=m.target.value||".home",B=document.getElementById("tld-preview"),w=document.getElementById("tld-preview-2");B&&(B.textContent=f),w&&(w.textContent=f)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(m){m.preventDefault(),P("setup-step-1")});const T=document.getElementById("setup-homelab-next");T&&(T.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-tld")?.value?.trim()||"",B=document.getElementById("setup-ca-name")?.value?.trim()||"",w=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!f||!f.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!B){showNotification("Please enter a Certificate Authority name","warning");return}if(!w){showNotification("Please enter your DNS server IP address","warning");return}A()});const z=document.getElementById("setup-simple-back");z&&(z.onclick=function(m){m.preventDefault(),P("setup-step-1")});const C=document.getElementById("setup-simple-next");C&&(C.onclick=function(m){m.preventDefault(),A()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(m){m.onchange=function(){var f=document.getElementById("dns-requirement-note");f&&(f.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const M=document.getElementById("setup-public-back");M&&(M.onclick=function(m){m.preventDefault(),P("setup-step-1")});const b=document.getElementById("setup-public-next");b&&(b.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-public-domain")?.value?.trim()||"",B=document.getElementById("setup-public-email")?.value?.trim()||"";if(!f){showNotification("Please enter your domain name","warning");return}if(!B||!B.includes("@")){showNotification("Please enter a valid email address","warning");return}A()});const d=document.getElementById("setup-summary-back");d&&(d.onclick=function(m){m.preventDefault(),x==="homelab"?P("setup-step-homela Line truncated
<div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button>
</div>
</div>
</div>`);const E="custom-apps";let R=null,h=null;const P=document.getElementById("app-selector-modal"),A=document.getElementById("app-selector-grid");async function D(){try{const p=await(await fetch("/api/v1/apps/templates")).json();if(p.success)return R=p.templates,h=p.categories,!0}catch(o){x.logError("[AppSelector] Fetch Templates",o,{function:"fetchApiTemplates"})}return!1}async function j(o){try{return await(await fetch(`/api/v1/apps/ports/${o}/check`)).json()}catch(p){return x.logError("[AppSelector] Check Port",p,{function:"checkPortAvailability"}),{available:!0}}}async function g(o){try{const l=await(await fetch(`/api/v1/apps/ports/${o}/suggest`)).json();if(l.success)return l.suggestedPort}catch(p){x.logError("[AppSelector] Get Suggested Port",p,{function:"getSuggestedPort"})}return o}async function $(){if(A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!R&&!await D()){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}A.innerHTML="";const o={};for(const[l,y]of Object.entries(R)){const u=y.category||"Other";o[u]||(o[u]=[]),o[u].push({id:l,...y})}const p=h?Object.keys(h):Object.keys(o).sort();for(const l of p){const y=o[l];if(!y||y.length===0)continue;y.sort((e,s)=>(s.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const t=h?.[l]||{};u.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(l)}`,t.color&&(u.style.borderBottomColor=t.color),A.appendChild(u),y.forEach(e=>{const s=document.createElement("div");s.className="app-option";const n=e.isDashboardWidget,r=n&&safeGet("widget-"+e.id+"-enabled")!=="false",a=n?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${r?"#2ecc7130":"#e74c3c30"}; color: ${r?"#2ecc71":"#e74c3c"}; font-weight: 600;">${r?"ON":"OFF"}</div>`:"",i=!n&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";s.innerHTML=`
</div>`);const E="custom-apps";let j=null,h=null;const P=document.getElementById("app-selector-modal"),A=document.getElementById("app-selector-grid");async function D(){try{const u=await(await fetch("/api/v1/apps/templates")).json();if(u.success)return j=u.templates,h=u.categories,!0}catch(o){x.logError("[AppSelector] Fetch Templates",o,{function:"fetchApiTemplates"})}return!1}async function R(o){try{return await(await fetch(`/api/v1/apps/ports/${o}/check`)).json()}catch(u){return x.logError("[AppSelector] Check Port",u,{function:"checkPortAvailability"}),{available:!0}}}async function y(o){try{const l=await(await fetch(`/api/v1/apps/ports/${o}/suggest`)).json();if(l.success)return l.suggestedPort}catch(u){x.logError("[AppSelector] Get Suggested Port",u,{function:"getSuggestedPort"})}return o}async function $(){if(A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!await D()){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}A.innerHTML="";const o={};for(const[l,g]of Object.entries(j)){const p=g.category||"Other";o[p]||(o[p]=[]),o[p].push({id:l,...g})}const u=h?Object.keys(h):Object.keys(o).sort();for(const l of u){const g=o[l];if(!g||g.length===0)continue;g.sort((e,s)=>(s.popularity||0)-(e.popularity||0));const p=document.createElement("div");p.className="app-category-header";const t=h?.[l]||{};p.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(l)}`,t.color&&(p.style.borderBottomColor=t.color),A.appendChild(p),g.forEach(e=>{const s=document.createElement("div");s.className="app-option";const n=e.isDashboardWidget,r=n&&safeGet("widget-"+e.id+"-enabled")!=="false",a=n?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${r?"#2ecc7130":"#e74c3c30"}; color: ${r?"#2ecc71":"#e74c3c"}; font-weight: 600;">${r?"ON":"OFF"}</div>`:"",i=!n&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";s.innerHTML=`
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${a}${i}
`,n?s.onclick=()=>k(e,s):s.onclick=()=>M(e),A.appendChild(s)})}window.renderRecipeCards&&await window.renderRecipeCards(A)}function k(o,p){const l="widget-"+o.id+"-enabled",u=!(safeGet(l)!=="false");safeSet(l,String(u));const t=o.widgetSelector;if(t){const s=document.querySelector(t);s&&(s.style.display=u?"":"none")}const e=p.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(`${o.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function M(o){const p=document.getElementById("app-deploy-modal"),l=document.getElementById("app-deploy-title"),y=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),s=document.getElementById("deploy-tailscale-only"),n=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:o.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${o.name} container:
`,n?s.onclick=()=>k(e,s):s.onclick=()=>H(e),A.appendChild(s)})}window.renderRecipeCards&&await window.renderRecipeCards(A)}function k(o,u){const l="widget-"+o.id+"-enabled",p=!(safeGet(l)!=="false");safeSet(l,String(p));const t=o.widgetSelector;if(t){const s=document.querySelector(t);s&&(s.style.display=p?"":"none")}const e=u.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=p?"ON":"OFF",e.style.background=p?"#2ecc7130":"#e74c3c30",e.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${o.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function H(o){const u=document.getElementById("app-deploy-modal"),l=document.getElementById("app-deploy-title"),g=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),s=document.getElementById("deploy-tailscale-only"),n=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:o.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${o.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.`)&&(o._useExisting=!0,o._existingContainer=V)}}catch{}l.textContent=`Deploy ${o.name}`;const r=o.subdomain||o.id.replace(/-/g,"");y.value=r;const a=document.getElementById("subpath-compat-warning");if(a)if(SITE.routingMode==="subdirectory"){const W=o.subpathSupport||"strip";W==="none"?(a.style.display="block",a.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+o.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(a.style.display="block",a.innerHTML='<span style="color: var(--muted);">&#9432; '+o.name+" has unverified subdirectory support. It may require additional configuration.</span>"):a.style.display="none"}else a.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),I=document.querySelector(`input[name="dns-type"][value="${i}"]`),N=document.querySelector(`input[name="ssl-type"][value="${c}"]`);I?I.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,N?N.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",s.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(o.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(o.id==="plex"||o.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="",o.docker?.volumes?.length){const W=o.mediaMount?.containerPath,G=o.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
Click Cancel to deploy a new container.`)&&(o._useExisting=!0,o._existingContainer=V)}}catch{}l.textContent=`Deploy ${o.name}`;const r=o.subdomain||o.id.replace(/-/g,"");g.value=r;const a=document.getElementById("subpath-compat-warning");if(a)if(SITE.routingMode==="subdirectory"){const W=o.subpathSupport||"strip";W==="none"?(a.style.display="block",a.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+o.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(a.style.display="block",a.innerHTML='<span style="color: var(--muted);">&#9432; '+o.name+" has unverified subdirectory support. It may require additional configuration.</span>"):a.style.display="none"}else a.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),I=document.querySelector(`input[name="dns-type"][value="${i}"]`),N=document.querySelector(`input[name="ssl-type"][value="${c}"]`);I?I.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,N?N.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",s.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(o.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(o.id==="plex"||o.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="",o.docker?.volumes?.length){const W=o.mediaMount?.containerPath,G=o.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=o.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await j(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=o.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await R(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await y(se);Y.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span>
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?n.innerHTML=`
<span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
`:G.installed?n.innerHTML='<span style="color: #ff9800;">Not connected</span>':(n.innerHTML='<span style="color: var(--muted);">Not available</span>',s.disabled=!0)}catch{n.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=y.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||o.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}u.textContent=K}y.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),P.classList.remove("show"),p.classList.add("show"),p.dataset.appTemplate=JSON.stringify(o)}async function T(o){const p=o.appTemplate,l=safeGetJSON(E,[]),y=p._useExisting&&p._existingContainer,u=l.find(t=>t.id===o.subdomain);if(!(u&&!y&&!confirm(`An app with subdomain "${o.subdomain}" already exists. Redeploy?`))){if(u){const t=l.indexOf(u);l.splice(t,1),safeSet(E,JSON.stringify(l))}if(y)o.port=p._existingContainer.primaryPort;else{const t=o.port||p.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await j(t);if(!e.available){const s=await g(p.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
`:G.installed?n.innerHTML='<span style="color: #ff9800;">Not connected</span>':(n.innerHTML='<span style="color: var(--muted);">Not available</span>',s.disabled=!0)}catch{n.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=g.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||o.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}p.textContent=K}g.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),P.classList.remove("show"),u.classList.add("show"),u.dataset.appTemplate=JSON.stringify(o)}async function T(o){const u=o.appTemplate,l=safeGetJSON(E,[]),g=u._useExisting&&u._existingContainer,p=l.find(t=>t.id===o.subdomain);if(!(p&&!g&&!confirm(`An app with subdomain "${o.subdomain}" already exists. Redeploy?`))){if(p){const t=l.indexOf(p);l.splice(t,1),safeSet(E,JSON.stringify(l))}if(g)o.port=u._existingContainer.primaryPort;else{const t=o.port||u.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await R(t);if(!e.available){const s=await y(u.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
Would you like to use port ${s} instead?`))o.port=s;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(y?`Configuring ${p.name} with existing container...`:`Deploying ${p.name}...`,"info",0);try{const t={appId:p.id,config:{subdomain:o.subdomain,ip:o.ip,createDns:o.dnsType==="private",port:o.port||p.defaultPort||null,sslType:o.sslType,dnsType:o.dnsType,tailscaleOnly:o.tailscaleOnly||!1,mediaPath:o.mediaPath||null,plexClaimToken:o.plexClaimToken||null,customVolumes:o.customVolumes||null}};y&&(t.config.useExisting=!0,t.config.existingContainerId=p._existingContainer.id,t.config.existingPort=p._existingContainer.primaryPort,!o.port&&p._existingContainer.primaryPort&&(t.config.port=p._existingContainer.primaryPort));const e={app:p.id,port:o.port||p.defaultPort||null,subdomain:o.subdomain||"",media_path:o.mediaPath||"",env:o.plexClaimToken?{PLEX_CLAIM:o.plexClaimToken}:{}},n=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();if(n.success){const r={id:o.subdomain,name:p.name,logo:`/assets/${p.id}.png`,containerId:null,url:n.url||`http://127.0.0.1:${e.port}`,ip:o.ip,appTemplate:p.id,tailscaleOnly:o.tailscaleOnly||!1};l.push(r),safeSet(E,JSON.stringify(l)),window.APPS&&!window.APPS.some(i=>i.id===p.id)&&(window.APPS.push(r),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let a=n.usedExisting?`${p.name} configured with existing container!
URL: ${n.url}`:`${p.name} deployed successfully!
Would you like to use port ${s} instead?`))o.port=s;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(g?`Configuring ${u.name} with existing container...`:`Deploying ${u.name}...`,"info",0);try{const t={appId:u.id,config:{subdomain:o.subdomain,ip:o.ip,createDns:o.dnsType==="private",port:o.port||u.defaultPort||null,sslType:o.sslType,dnsType:o.dnsType,tailscaleOnly:o.tailscaleOnly||!1,mediaPath:o.mediaPath||null,plexClaimToken:o.plexClaimToken||null,customVolumes:o.customVolumes||null}};g&&(t.config.useExisting=!0,t.config.existingContainerId=u._existingContainer.id,t.config.existingPort=u._existingContainer.primaryPort,!o.port&&u._existingContainer.primaryPort&&(t.config.port=u._existingContainer.primaryPort));const e={app:u.id,port:o.port||u.defaultPort||null,subdomain:o.subdomain||"",media_path:o.mediaPath||"",env:o.plexClaimToken?{PLEX_CLAIM:o.plexClaimToken}:{}},n=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();if(n.success){const r={id:o.subdomain,name:u.name,logo:`/assets/${u.id}.png`,containerId:null,url:n.url||`http://127.0.0.1:${e.port}`,ip:o.ip,appTemplate:u.id,tailscaleOnly:o.tailscaleOnly||!1};l.push(r),safeSet(E,JSON.stringify(l)),window.APPS&&!window.APPS.some(i=>i.id===u.id)&&(window.APPS.push(r),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let a=n.usedExisting?`${u.name} configured with existing container!
URL: ${n.url}`:`${u.name} deployed successfully!
URL: ${n.url}`;n.warning&&(a+=`
\u26A0 Warning: ${n.warning}`),showNotification(a,"success",8e3),delete p._useExisting,delete p._existingContainer,n.url&&n.url.startsWith("https://")&&z(n.url,p.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const i=n.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${p.name}: ${i}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(t){x.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${p.name}: ${t.message}`,"error",8e3)}}}async function z(o,p){showNotification(`\u23F3 Generating SSL certificate for ${p}...`,"warning",6e4);let l=0;const y=12,u=async()=>{l++;try{const t=await fetch(o,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${p} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<y?setTimeout(u,5e3):showNotification(`\u26A0\uFE0F ${p} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(u,3e3)}function C(){safeGetJSON(E,[]).forEach(p=>{window.APPS.some(l=>l.id===p.id)||window.APPS.push(p)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{$(),P.classList.add("show")}),wireModal(P,document.getElementById("app-selector-cancel"));const H=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{H.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const o=JSON.parse(H.dataset.appTemplate),p=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{l.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const y={appTemplate:o,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:p||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!y.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(o.mediaMount?.required&&!p){showNotification("Please enter a media library path for this application","warning");return}H.classList.remove("show"),T(y)}),wireModal(H);const b=document.getElementById("folder-browser-modal"),d=document.getElementById("folder-browser-path"),v=document.getElementById("folder-browser-list"),S=document.getElementById("folder-browser-selected"),O=document.getElementById("folder-browser-selected-list");let m="",f=[],B=null;window.openFolderBrowser=function(o){B=o,f=o.value.split(",").map(p=>p.trim()).filter(p=>p),m="",L(),w(""),b.classList.add("show")};async function w(o){d.textContent=o||"Select a drive...",v.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(o)}`)).json();if(!l.success){v.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}m=l.path||"",d.textContent=m||"Select a drive...";let y="";l.parent&&l.parent!==l.path&&(y+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
\u26A0 Warning: ${n.warning}`),showNotification(a,"success",8e3),delete u._useExisting,delete u._existingContainer,n.url&&n.url.startsWith("https://")&&z(n.url,u.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const i=n.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${u.name}: ${i}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(t){x.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${u.name}: ${t.message}`,"error",8e3)}}}async function z(o,u){showNotification(`\u23F3 Generating SSL certificate for ${u}...`,"warning",6e4);let l=0;const g=12,p=async()=>{l++;try{const t=await fetch(o,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${u} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<g?setTimeout(p,5e3):showNotification(`\u26A0\uFE0F ${u} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(p,3e3)}function C(){safeGetJSON(E,[]).forEach(u=>{window.APPS.some(l=>l.id===u.id)||window.APPS.push(u)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{$(),P.classList.add("show")}),window.openDeployConfigFor=async function(o){if(!j&&!await D())return!1;const u=String(o||"").toLowerCase(),l=Object.entries(j).find(([p])=>p.toLowerCase()===u);if(!l)return!1;const g={id:l[0],...l[1]};return g.isDashboardWidget?(await $(),P.classList.add("show"),"selector"):(await H(g),!0)},wireModal(P,document.getElementById("app-selector-cancel"));const M=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const o=JSON.parse(M.dataset.appTemplate),u=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{l.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const g={appTemplate:o,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:u||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!g.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(o.mediaMount?.required&&!u){showNotification("Please enter a media library path for this application","warning");return}M.classList.remove("show"),T(g)}),wireModal(M);const b=document.getElementById("folder-browser-modal"),d=document.getElementById("folder-browser-path"),v=document.getElementById("folder-browser-list"),S=document.getElementById("folder-browser-selected"),O=document.getElementById("folder-browser-selected-list");let m="",f=[],B=null;window.openFolderBrowser=function(o){B=o,f=o.value.split(",").map(u=>u.trim()).filter(u=>u),m="",L(),w(""),b.classList.add("show")};async function w(o){d.textContent=o||"Select a drive...",v.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(o)}`)).json();if(!l.success){v.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}m=l.path||"",d.textContent=m||"Select a drive...";let g="";l.parent&&l.parent!==l.path&&(g+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span>
</div>`),l.items.length===0&&!l.parent?y+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?y+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(u=>{const t=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=f.includes(u.path),s=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";y+=`<div class="folder-item" data-path="${escapeHtml(u.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${s}">
</div>`),l.items.length===0&&!l.parent?g+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?g+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(p=>{const t=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=f.includes(p.path),s=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";g+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${s}">
<span style="font-size: 1.2rem;">${t}</span>
<span style="flex: 1;">${escapeHtml(u.name)}</span>
<span style="flex: 1;">${escapeHtml(p.name)}</span>
${e?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),v.innerHTML=y,v.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{w(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const t=f.includes(u.dataset.path);u.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(p){v.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(p.message)}</div>`}}function L(){if(f.length===0){S.style.display="none";return}S.style.display="block",O.innerHTML=f.map(o=>`
</div>`}),v.innerHTML=g,v.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{w(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const t=f.includes(p.dataset.path);p.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(u){v.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(u.message)}</div>`}}function L(){if(f.length===0){S.style.display="none";return}S.style.display="block",O.innerHTML=f.map(o=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
${escapeHtml(o)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(o)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span>
`).join("")}window.removeSelectedFolder=function(o){f=f.filter(p=>p!==o),L(),w(m)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{m&&!f.includes(m)&&(f.push(m),L(),w(m))}),wireModal(b,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{B&&(B.value=f.join(", ")),b.classList.remove("show")}),C()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
`).join("")}window.removeSelectedFolder=function(o){f=f.filter(u=>u!==o),L(),w(m)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{m&&!f.includes(m)&&(f.push(m),L(),w(m))}),wireModal(b,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{B&&(B.value=f.join(", ")),b.classList.remove("show")}),C()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,13 +445,13 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="recipe-next" class="btn-accent-solid">Next</button>
</div>
</div>
</div>`);let x=null,E=null,R=null,h=1,P=!1;const A=document.getElementById("recipe-deploy-modal"),D=document.getElementById("recipe-cancel"),j=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(A,D);async function $(){try{const m=await fetch("/api/v1/recipes/templates"),f=await m.json();if(f.success)return x=f.templates,E=f.categories,!0;if(m.status===403)return P=!1,!1}catch(m){console.warn("Failed to fetch recipe templates:",m.message)}return!1}async function k(){try{P=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{P=!1}return P}window.renderRecipeCards=async function(m){await k();let f;if(P&&x?f=x:f=M(),!f||f.length===0)return;const B=document.createElement("div");B.className="app-category-header",B.innerHTML="\u{1F9EA} Recipes",B.style.borderBottomColor="#8e44ad",m.appendChild(B);const w=Array.isArray(f)?f:Object.values(f);w.sort((L,o)=>(o.popularity||0)-(L.popularity||0));for(const L of w){const o=document.createElement("div");o.className="app-option",o.style.position="relative";const p=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${L.componentCount||L.components?.length||"?"} apps</div>`,l=P?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';o.innerHTML=`
</div>`);let x=null,E=null,j=null,h=1,P=!1;const A=document.getElementById("recipe-deploy-modal"),D=document.getElementById("recipe-cancel"),R=document.getElementById("recipe-prev"),y=document.getElementById("recipe-next");wireModal(A,D);async function $(){try{const m=await fetch("/api/v1/recipes/templates"),f=await m.json();if(f.success)return x=f.templates,E=f.categories,!0;if(m.status===403)return P=!1,!1}catch(m){console.warn("Failed to fetch recipe templates:",m.message)}return!1}async function k(){try{P=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{P=!1}return P}window.renderRecipeCards=async function(m){await k();let f;if(P&&x?f=x:f=H(),!f||f.length===0)return;const B=document.createElement("div");B.className="app-category-header",B.innerHTML="\u{1F9EA} Recipes",B.style.borderBottomColor="#8e44ad",m.appendChild(B);const w=Array.isArray(f)?f:Object.values(f);w.sort((L,o)=>(o.popularity||0)-(L.popularity||0));for(const L of w){const o=document.createElement("div");o.className="app-option",o.style.position="relative";const u=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${L.componentCount||L.components?.length||"?"} apps</div>`,l=P?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';o.innerHTML=`
${l}
<div class="app-option-icon">${escapeHtml(L.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(L.name)}</div>
<div class="app-option-desc">${escapeHtml(L.description||"")}</div>
${p}
`,o.onclick=()=>{if(!P){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}T(L)},m.appendChild(o)}};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 T(m){R=m,h=1;const f=document.getElementById("app-selector-modal");f&&f.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${m.name}`,z(),C(),A.classList.add("show")}function z(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(m=>{const f=parseInt(m.dataset.step);m.classList.toggle("active",f===h),m.classList.toggle("completed",f<h)});for(let m=1;m<=4;m++){const f=document.getElementById(`recipe-step-${m}`);f&&(f.style.display=m===h?"":"none")}j.style.display=h>1&&h<4?"":"none",h===4?(g.style.display="none",D.textContent="Close"):h===3?(g.textContent="\u{1F680} Deploy",g.style.display="",D.textContent="Cancel"):(g.textContent="Next",g.style.display="",D.textContent="Cancel")}function C(){const m=document.getElementById("recipe-component-list");m.innerHTML="";const f=R.components||[];for(const B of f){const w=document.createElement("div");w.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const L=B.required,o=B.internal;w.innerHTML=`
${u}
`,o.onclick=()=>{if(!P){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}T(L)},m.appendChild(o)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function T(m){j=m,h=1;const f=document.getElementById("app-selector-modal");f&&f.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${m.name}`,z(),C(),A.classList.add("show")}function z(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(m=>{const f=parseInt(m.dataset.step);m.classList.toggle("active",f===h),m.classList.toggle("completed",f<h)});for(let m=1;m<=4;m++){const f=document.getElementById(`recipe-step-${m}`);f&&(f.style.display=m===h?"":"none")}R.style.display=h>1&&h<4?"":"none",h===4?(y.style.display="none",D.textContent="Close"):h===3?(y.textContent="\u{1F680} Deploy",y.style.display="",D.textContent="Cancel"):(y.textContent="Next",y.style.display="",D.textContent="Cancel")}function C(){const m=document.getElementById("recipe-component-list");m.innerHTML="";const f=j.components||[];for(const B of f){const w=document.createElement("div");w.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const L=B.required,o=B.internal;w.innerHTML=`
<input type="checkbox" ${L?"checked disabled":"checked"} data-component-id="${B.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;">
@@ -463,15 +463,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>
${B.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(B.note)}</div>`:""}
</div>
`,m.appendChild(w)}}function H(){const m=document.getElementById("recipe-volumes-section"),f=document.getElementById("recipe-volume-list"),B=R.sharedVolumes;if(B&&Object.keys(B).length>0){m.style.display="",f.innerHTML="";for(const[w,L]of Object.entries(B)){const o=document.createElement("div");o.style.cssText="display: grid; gap: 4px;",o.innerHTML=`
`,m.appendChild(w)}}function M(){const m=document.getElementById("recipe-volumes-section"),f=document.getElementById("recipe-volume-list"),B=j.sharedVolumes;if(B&&Object.keys(B).length>0){m.style.display="",f.innerHTML="";for(const[w,L]of Object.entries(B)){const o=document.createElement("div");o.style.cssText="display: grid; gap: 4px;",o.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(L.label||w)}</label>
<input type="text" data-volume-key="${w}" value="${escapeHtml(L.defaultPath||"")}"
placeholder="${escapeHtml(L.defaultPath||"/path")}"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
<div class="form-hint-sm">${escapeHtml(L.description||"")}</div>
`,f.appendChild(o)}}else m.style.display="none"}function b(){const m=document.getElementById("recipe-review-content"),f=d(),B=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),w={};B.forEach(l=>{w[l.dataset.volumeKey]=l.value});const L=document.getElementById("recipe-timezone").value||"UTC",o=document.getElementById("recipe-ip").value||"host.docker.internal",p=document.getElementById("recipe-tailscale").checked;m.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(R.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(R.description||"")}</div>
`,f.appendChild(o)}}else m.style.display="none"}function b(){const m=document.getElementById("recipe-review-content"),f=d(),B=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),w={};B.forEach(l=>{w[l.dataset.volumeKey]=l.value});const L=document.getElementById("recipe-timezone").value||"UTC",o=document.getElementById("recipe-ip").value||"host.docker.internal",u=document.getElementById("recipe-tailscale").checked;m.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(j.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(j.description||"")}</div>
<div style="margin-bottom: 12px;">
<strong>Components (${f.length}):</strong>
@@ -484,31 +484,31 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
${Object.keys(w).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong>
${Object.entries(w).map(([l,y])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${l}: ${escapeHtml(y)}</div>`).join("")}
${Object.entries(w).map(([l,g])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${l}: ${escapeHtml(g)}</div>`).join("")}
</div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(L)} &bull; IP: ${escapeHtml(o)} ${p?"&bull; Tailscale only":""}
Timezone: ${escapeHtml(L)} &bull; IP: ${escapeHtml(o)} ${u?"&bull; Tailscale only":""}
</div>
${R.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(R.network.name)}</code></div>`:""}
`}function d(){const m=document.querySelectorAll("#recipe-component-list input[data-component-id]"),f=new Set;m.forEach(w=>{w.checked&&f.add(w.dataset.componentId)});const B=R.components||[];return B.filter(w=>w.required).forEach(w=>f.add(w.id)),B.filter(w=>f.has(w.id))}async function v(){const m=document.getElementById("recipe-progress-list"),f=document.getElementById("recipe-deploy-result");f.style.display="none",m.innerHTML="";const B=d();for(const p of B){const l=document.createElement("div");l.id=`recipe-progress-${p.id}`,l.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",l.innerHTML=`
${j.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(j.network.name)}</code></div>`:""}
`}function d(){const m=document.querySelectorAll("#recipe-component-list input[data-component-id]"),f=new Set;m.forEach(w=>{w.checked&&f.add(w.dataset.componentId)});const B=j.components||[];return B.filter(w=>w.required).forEach(w=>f.add(w.id)),B.filter(w=>f.has(w.id))}async function v(){const m=document.getElementById("recipe-progress-list"),f=document.getElementById("recipe-deploy-result");f.style.display="none",m.innerHTML="";const B=d();for(const u of B){const l=document.createElement("div");l.id=`recipe-progress-${u.id}`,l.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",l.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(p.role||p.id)}</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(u.role||u.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,m.appendChild(l)}const w=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),L={};w.forEach(p=>{L[p.dataset.volumeKey]=p.value});const o={selectedComponents:B.map(p=>p.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:L},componentOverrides:{}};for(const p of B)S(p.id,"deploying","Deploying...");try{const l=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:R.id,config:o})})).json();if(l.success){for(const y of l.deployed||[])S(y.id,"success",y.url?`Running \u2192 ${y.url}`:"Running");for(const y of l.errors||[])S(y.componentId,"error",y.error);f.style.display="",f.innerHTML=`
`,m.appendChild(l)}const w=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),L={};w.forEach(u=>{L[u.dataset.volumeKey]=u.value});const o={selectedComponents:B.map(u=>u.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:L},componentOverrides:{}};for(const u of B)S(u.id,"deploying","Deploying...");try{const l=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:j.id,config:o})})).json();if(l.success){for(const g of l.deployed||[])S(g.id,"success",g.url?`Running \u2192 ${g.url}`:"Running");for(const g of l.errors||[])S(g.componentId,"error",g.error);f.style.display="",f.innerHTML=`
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(l.message||"Deployed!")}</div>
${l.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${l.setupInstructions.map(y=>`<li>${escapeHtml(y)}</li>`).join("")}</ul>
<ul style="margin: 4px 0 0 16px; padding: 0;">${l.setupInstructions.map(g=>`<li>${escapeHtml(g)}</li>`).join("")}</ul>
</div>`:""}
</div>
`,showNotification(`${R.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else f.style.display="",f.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
`,showNotification(`${j.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else f.style.display="",f.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(l.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(p){f.style.display="",f.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(p.message)}
</div>`}}function S(m,f,B){const w=document.getElementById(`recipe-progress-${m}`);if(!w)return;const L=w.querySelector(".recipe-progress-icon"),o=w.querySelector(".recipe-progress-status");f==="deploying"?(L.textContent="\u23F3",o.style.color="var(--accent)"):f==="success"?(L.textContent="\u2705",o.style.color="var(--ok-fg)"):f==="error"&&(L.textContent="\u274C",o.style.color="var(--bad-fg)"),o.textContent=B}g.addEventListener("click",()=>{if(h===3){h=4,z(),v();return}h<3&&(h++,z(),h===2&&H(),h===3&&b())}),j.addEventListener("click",()=>{h>1&&h<4&&(h--,z())}),window.groupRecipeCards=function(){const m=document.querySelectorAll(".service-card[data-recipe-id]");if(m.length===0)return;const f={};m.forEach(B=>{const w=B.dataset.recipeId;f[w]||(f[w]=[]),f[w].push(B)});for(const[B,w]of Object.entries(f))w.length<2||w.forEach((L,o)=>{if(L.style.borderLeft="3px solid rgba(142,68,173,0.5)",o===0){let p=L.querySelector(".recipe-group-label");p||(p=document.createElement("div"),p.className="recipe-group-label",p.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",p.textContent=B.replace(/-/g," "),L.style.position="relative",L.appendChild(p))}})},window.manageRecipe=async function(m,f){const B=`/api/v1/recipes/${m}/${f}`,w=f==="remove"?"DELETE":"POST",L=f==="remove"?`/api/v1/recipes/${m}`:B;if(!(f==="remove"&&!confirm(`Remove the entire ${m} recipe? This will delete all containers and configuration.`)))try{const p=await(await secureFetch(L,{method:w})).json();p.success?(showNotification(`Recipe ${f}: ${p.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${f} failed: ${p.error}`,"error",5e3)}catch(o){showNotification(`Network error: ${o.message}`,"error",5e3)}};const O=document.createElement("style");O.textContent=`
</div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(u){f.style.display="",f.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(u.message)}
</div>`}}function S(m,f,B){const w=document.getElementById(`recipe-progress-${m}`);if(!w)return;const L=w.querySelector(".recipe-progress-icon"),o=w.querySelector(".recipe-progress-status");f==="deploying"?(L.textContent="\u23F3",o.style.color="var(--accent)"):f==="success"?(L.textContent="\u2705",o.style.color="var(--ok-fg)"):f==="error"&&(L.textContent="\u274C",o.style.color="var(--bad-fg)"),o.textContent=B}y.addEventListener("click",()=>{if(h===3){h=4,z(),v();return}h<3&&(h++,z(),h===2&&M(),h===3&&b())}),R.addEventListener("click",()=>{h>1&&h<4&&(h--,z())}),window.groupRecipeCards=function(){const m=document.querySelectorAll(".service-card[data-recipe-id]");if(m.length===0)return;const f={};m.forEach(B=>{const w=B.dataset.recipeId;f[w]||(f[w]=[]),f[w].push(B)});for(const[B,w]of Object.entries(f))w.length<2||w.forEach((L,o)=>{if(L.style.borderLeft="3px solid rgba(142,68,173,0.5)",o===0){let u=L.querySelector(".recipe-group-label");u||(u=document.createElement("div"),u.className="recipe-group-label",u.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;",u.textContent=B.replace(/-/g," "),L.style.position="relative",L.appendChild(u))}})},window.manageRecipe=async function(m,f){const B=`/api/v1/recipes/${m}/${f}`,w=f==="remove"?"DELETE":"POST",L=f==="remove"?`/api/v1/recipes/${m}`:B;if(!(f==="remove"&&!confirm(`Remove the entire ${m} recipe? This will delete all containers and configuration.`)))try{const u=await(await secureFetch(L,{method:w})).json();u.success?(showNotification(`Recipe ${f}: ${u.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${f} failed: ${u.error}`,"error",5e3)}catch(o){showNotification(`Network error: ${o.message}`,"error",5e3)}};const O=document.createElement("style");O.textContent=`
.recipe-step {
flex: 1;
text-align: center;
@@ -550,7 +550,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel {
min-height: 180px;
}
`,document.head.appendChild(O),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const x=document.getElementById("reload-caddy-top"),E=x.textContent;try{x.textContent="\u23F3 Reloading...",x.disabled=!0;const R=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),h=await R.json();if(R.ok&&h.success)x.textContent="\u2705 Reloaded!",setTimeout(()=>{x.textContent=E,x.disabled=!1},2e3);else throw new Error(h.error||"Reload failed")}catch(R){x.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${R.message}`,"error"),setTimeout(()=>{x.textContent=E,x.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal">
`,document.head.appendChild(O),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const x=document.getElementById("reload-caddy-top"),E=x.textContent;try{x.textContent="\u23F3 Reloading...",x.disabled=!0;const j=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),h=await j.json();if(j.ok&&h.success)x.textContent="\u2705 Reloaded!",setTimeout(()=>{x.textContent=E,x.disabled=!1},2e3);else throw new Error(h.error||"Reload failed")}catch(j){x.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${j.message}`,"error"),setTimeout(()=>{x.textContent=E,x.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>\u{1F4CB} Error Logs</h3>
<p class="modal-subtitle">
@@ -597,8 +597,8 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="error-log-close">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("error-log-modal"),E=document.getElementById("view-error-logs"),R=document.getElementById("error-log-refresh"),h=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close"),A=document.getElementById("error-log-level"),D=document.getElementById("error-log-context"),j=document.getElementById("error-log-search"),g=document.getElementById("error-log-since"),$=document.getElementById("error-log-until"),k=document.getElementById("error-log-container"),M=document.getElementById("error-log-load-more"),T=document.getElementById("error-log-total"),z=50;let C=0,H=null,b=0,d=[];function v(L){if(!L)return null;const o=new Date(L);return isNaN(o.getTime())?null:o.toISOString()}async function S(){try{const L=await fetch("/api/v1/error-logs/contexts");if(!L.ok)return;const o=await L.json();if(!o.success||!Array.isArray(o.contexts))return;d=o.contexts;const p=D.value;D.innerHTML='<option value="">All</option>';for(const l of o.contexts){const y=document.createElement("option");y.value=l.name,y.textContent=`${l.name} (${l.count})`,D.appendChild(y)}p&&o.contexts.some(l=>l.name===p)&&(D.value=p)}catch{}}function O(){const L=new URLSearchParams;L.set("limit",String(z)),L.set("offset",String(C)),A.value&&L.set("level",A.value),D.value&&L.set("context",D.value);const o=v(g.value),p=v($.value);o&&L.set("since",o),p&&L.set("until",p);const l=(j.value||"").trim();return l&&L.set("search",l),L}async function m(L){try{L?(H&&H.abort(),H=new AbortController):(H&&H.abort(),H=new AbortController,C=0,b++,k.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const o=b,p=O(),l=await fetch("/api/v1/error-logs?"+p.toString(),{signal:H.signal});if(!l.ok){k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,M.style.display="none",T.textContent="";return}const y=await l.json();if(!y.success){k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.error||"unknown")}</div>`,M.style.display="none",T.textContent="";return}if(!L&&o!==b)return;const u=Array.isArray(y.logs)?y.logs:[];if(u.length===0&&!L){const e=y.filters&&(y.filters.level||y.filters.context||y.filters.search||y.filters.since||y.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";k.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,M.style.display="none",T.textContent=y.total?`${y.total} total`:"";return}let t="";L||(t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',t+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',t+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',t+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',t+='<th style="padding: 6px; text-align: left;">Message</th>',t+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',t+="</tr>");for(const e of u){const s=(e.level||"?").toUpperCase(),n=s==="ERR"?"var(--bad-fg)":s==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",r=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",a=e.context||"\u2014",i=(e.error||"").split(`
`)[0],c=e.request&&e.request.ip||"";t+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',t+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(r)}</td>`,t+=`<td style="padding: 6px;"><span style="color: ${n}; font-weight: 600;">${escapeHtml(s)}</span></td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(a)}</td>`,t+=`<td style="padding: 6px;">${escapeHtml(i)}</td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c)}</td>`,t+="</tr>",e.detail&&(t+=`<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!L)t+="</table>",k.innerHTML=t;else{const e=k.querySelector("table");e&&e.insertAdjacentHTML("beforeend",t)}C+=u.length,M.style.display=y.hasMore?"":"none",T.textContent=`${y.total} total${y.hasMore?" (showing "+C+")":""}`,k.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const s=e.nextElementSibling;s&&s.classList.contains("error-log-detail")&&(s.style.display=s.style.display==="none"?"":"none")}))})}catch(o){if(o&&o.name==="AbortError")return;k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(o.message)}</div>`,T.textContent=""}}async function f(){if(confirm("Clear the entire error log? This cannot be undone."))try{const o=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();o.success?(await S(),m(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(o.error||"Clear failed"),"error",4e3)}catch(L){showNotification("\u274C "+L.message,"error",4e3)}}let B;function w(){A?.addEventListener("change",()=>m(!1)),D?.addEventListener("change",()=>m(!1)),j?.addEventListener("input",()=>{clearTimeout(B),B=setTimeout(()=>m(!1),250)});let L;[g,$].forEach(o=>{o?.addEventListener("change",()=>{clearTimeout(L),L=setTimeout(()=>m(!1),250)})}),R?.addEventListener("click",()=>m(!1)),M?.addEventListener("click",()=>m(!0)),h?.addEventListener("click",f),wireModal(x,P)}E?.addEventListener("click",async()=>{x?.classList.add("show"),await S(),m(!1)}),w()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
</div>`);const x=document.getElementById("error-log-modal"),E=document.getElementById("view-error-logs"),j=document.getElementById("error-log-refresh"),h=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close"),A=document.getElementById("error-log-level"),D=document.getElementById("error-log-context"),R=document.getElementById("error-log-search"),y=document.getElementById("error-log-since"),$=document.getElementById("error-log-until"),k=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),T=document.getElementById("error-log-total"),z=50;let C=0,M=null,b=0,d=[];function v(L){if(!L)return null;const o=new Date(L);return isNaN(o.getTime())?null:o.toISOString()}async function S(){try{const L=await fetch("/api/v1/error-logs/contexts");if(!L.ok)return;const o=await L.json();if(!o.success||!Array.isArray(o.contexts))return;d=o.contexts;const u=D.value;D.innerHTML='<option value="">All</option>';for(const l of o.contexts){const g=document.createElement("option");g.value=l.name,g.textContent=`${l.name} (${l.count})`,D.appendChild(g)}u&&o.contexts.some(l=>l.name===u)&&(D.value=u)}catch{}}function O(){const L=new URLSearchParams;L.set("limit",String(z)),L.set("offset",String(C)),A.value&&L.set("level",A.value),D.value&&L.set("context",D.value);const o=v(y.value),u=v($.value);o&&L.set("since",o),u&&L.set("until",u);const l=(R.value||"").trim();return l&&L.set("search",l),L}async function m(L){try{L?(M&&M.abort(),M=new AbortController):(M&&M.abort(),M=new AbortController,C=0,b++,k.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const o=b,u=O(),l=await fetch("/api/v1/error-logs?"+u.toString(),{signal:M.signal});if(!l.ok){k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,H.style.display="none",T.textContent="";return}const g=await l.json();if(!g.success){k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.error||"unknown")}</div>`,H.style.display="none",T.textContent="";return}if(!L&&o!==b)return;const p=Array.isArray(g.logs)?g.logs:[];if(p.length===0&&!L){const e=g.filters&&(g.filters.level||g.filters.context||g.filters.search||g.filters.since||g.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";k.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,H.style.display="none",T.textContent=g.total?`${g.total} total`:"";return}let t="";L||(t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',t+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',t+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',t+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',t+='<th style="padding: 6px; text-align: left;">Message</th>',t+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',t+="</tr>");for(const e of p){const s=(e.level||"?").toUpperCase(),n=s==="ERR"?"var(--bad-fg)":s==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",r=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",a=e.context||"\u2014",i=(e.error||"").split(`
`)[0],c=e.request&&e.request.ip||"";t+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',t+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(r)}</td>`,t+=`<td style="padding: 6px;"><span style="color: ${n}; font-weight: 600;">${escapeHtml(s)}</span></td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(a)}</td>`,t+=`<td style="padding: 6px;">${escapeHtml(i)}</td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c)}</td>`,t+="</tr>",e.detail&&(t+=`<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!L)t+="</table>",k.innerHTML=t;else{const e=k.querySelector("table");e&&e.insertAdjacentHTML("beforeend",t)}C+=p.length,H.style.display=g.hasMore?"":"none",T.textContent=`${g.total} total${g.hasMore?" (showing "+C+")":""}`,k.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const s=e.nextElementSibling;s&&s.classList.contains("error-log-detail")&&(s.style.display=s.style.display==="none"?"":"none")}))})}catch(o){if(o&&o.name==="AbortError")return;k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(o.message)}</div>`,T.textContent=""}}async function f(){if(confirm("Clear the entire error log? This cannot be undone."))try{const o=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();o.success?(await S(),m(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(o.error||"Clear failed"),"error",4e3)}catch(L){showNotification("\u274C "+L.message,"error",4e3)}}let B;function w(){A?.addEventListener("change",()=>m(!1)),D?.addEventListener("change",()=>m(!1)),R?.addEventListener("input",()=>{clearTimeout(B),B=setTimeout(()=>m(!1),250)});let L;[y,$].forEach(o=>{o?.addEventListener("change",()=>{clearTimeout(L),L=setTimeout(()=>m(!1),250)})}),j?.addEventListener("click",()=>m(!1)),H?.addEventListener("click",()=>m(!0)),h?.addEventListener("click",f),wireModal(x,P)}E?.addEventListener("click",async()=>{x?.classList.add("show"),await S(),m(!1)}),w()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
@@ -648,14 +648,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),R=document.getElementById("cl-log-content"),h=document.getElementById("cl-log-search"),P=document.getElementById("cl-log-tail"),A=document.getElementById("cl-refresh"),D=document.getElementById("cl-stream"),j=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),$=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),T=document.getElementById("cl-stream-indicator"),z=document.getElementById("cl-stream-text"),C=document.getElementById("cl-line-count"),H=document.getElementById("cl-filter-count"),b=document.getElementById("cl-image"),d=document.getElementById("cl-status"),v=document.getElementById("cl-created");let S=null,O=[],m=[],f=null,B=!1,w=null;function L(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function o(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function p(i,c){const I=i.stream==="stderr"?"log-stderr":"log-stdout",N=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
</div>`);const x=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),j=document.getElementById("cl-log-content"),h=document.getElementById("cl-log-search"),P=document.getElementById("cl-log-tail"),A=document.getElementById("cl-refresh"),D=document.getElementById("cl-stream"),R=document.getElementById("cl-download"),y=document.getElementById("cl-clear-search"),$=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),T=document.getElementById("cl-stream-indicator"),z=document.getElementById("cl-stream-text"),C=document.getElementById("cl-line-count"),M=document.getElementById("cl-filter-count"),b=document.getElementById("cl-image"),d=document.getElementById("cl-status"),v=document.getElementById("cl-created");let S=null,O=[],m=[],f=null,B=!1,w=null;function L(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function o(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function u(i,c){const I=i.stream==="stderr"?"log-stderr":"log-stdout",N=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${I}" data-index="${c}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${c+1}</span>
<span class="log-stream-icon" style="color: ${i.stream==="stderr"?"#f59e0b":"#22c55e"};">${N}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${o(i.text)}</span>
</div>
`}function l(i,c=""){if(!i||i.length===0){R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',C.textContent="0 lines",H.textContent="0 filtered";return}if(O=i,m=c?i.filter(I=>I.text&&I.text.toLowerCase().includes(c.toLowerCase())):i,C.textContent=`${i.length} lines`,H.textContent=c?`${m.length} of ${i.length} shown`:`${i.length} shown`,m.length===0){R.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${o(c)}"</div>`;return}R.innerHTML=m.map((I,N)=>p(I,N)).join(""),R.scrollTop=R.scrollHeight}async function y(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],I=E.value;E.innerHTML='<option value="">Select a container...</option>',c.forEach(N=>{const U=document.createElement("option");U.value=N.id,U.textContent=`${N.name} (${N.image.split(":")[0]}) - ${N.status}`,U.dataset.name=N.name,U.dataset.image=N.image,U.dataset.status=N.status,U.dataset.created=N.created,E.appendChild(U)}),I&&E.querySelector(`option[value="${I}"]`)&&(E.value=I,u(I))}catch(i){console.error("Failed to load containers:",i)}}function u(i){const c=E.querySelector(`option[value="${i}"]`);c&&(b.textContent=c.dataset.image||"-",d.textContent=c.dataset.status||"-",d.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",v.textContent=L(c.dataset.created))}async function t(){const i=E.value;if(!i){R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}s(),S=i,u(i);const c=P.value,I=h.value.trim();R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const N=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,U=await getJSON(N);U.logs&&U.logs.length>0?l(U.logs,I):(R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',C.textContent="0 lines",H.textContent="0 filtered")}catch(N){R.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${o(N.message)}</div>`}}function e(){const i=E.value;if(!i)return;s(),B=!0,D.textContent="\u23F9 Stop",M.style.display="flex",T.textContent="\u{1F7E2}",z.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;f=new EventSource(c),f.onopen=()=>{T.textContent="\u{1F7E2}",z.textContent="Connected - streaming logs"},f.onmessage=I=>{try{const N=JSON.parse(I.data);if(N.error){T.textContent="\u{1F534}",z.textContent=`Error: ${N.error}`;return}O.push(N),m.push(N),C.textContent=`${O.length} lines`,H.textContent=`${m.length} shown`;const U=h.value.trim();if(!U||N.text&&N.text.toLowerCase().includes(U.toLowerCase())){const _=document.createElement("div");_.innerHTML=p(N,m.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",R.appendChild(F),R.scrollTop=R.scrollHeight}}catch(N){console.error("Error parsing log:",N)}},f.onerror=()=>{T.textContent="\u{1F534}",z.textContent="Disconnected",B=!1,D.textContent="\u25B6 Stream"},x._eventSource=f}function s(){f&&(f.close(),f=null),x._eventSource&&(x._eventSource.close(),x._eventSource=null),B=!1,D.textContent="\u25B6 Stream",M.style.display="none"}function n(){if(!O||O.length===0){showNotification("No logs to download","error");return}const i=E.querySelector(`option[value="${S}"]`)?.dataset.name||S,c=new Date().toISOString().replace(/[:.]/g,"-"),I=`${i}-logs-${c}.txt`,N=O.map(q=>{const J=q.timestamp||"",X=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${q.text}`}).join(`
`),U=new Blob([N],{type:"text/plain"}),_=URL.createObjectURL(U),F=document.createElement("a");F.href=_,F.download=I,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${O.length} log lines`,"success")}E?.addEventListener("change",()=>{t()}),P?.addEventListener("change",()=>{t()}),A?.addEventListener("click",()=>{t()}),D?.addEventListener("click",()=>{B?s():e()}),j?.addEventListener("click",()=>{n()}),g?.addEventListener("click",()=>{h.value="",l(O,"")}),h?.addEventListener("input",()=>{clearTimeout(w),w=setTimeout(()=>{l(O,h.value.trim())},300)}),h?.addEventListener("keydown",i=>{i.key==="Escape"&&(h.value="",l(O,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{x.classList.add("show"),y()});function a(){s(),x.classList.remove("show")}$?.addEventListener("click",a),k?.addEventListener("click",a),document.addEventListener("keydown",i=>{i.key==="Escape"&&x.classList.contains("show")&&a()}),x.addEventListener("click",i=>{i.target===x&&a()}),window.openContainerLogsModal=function(i,c){x.classList.add("show"),y().then(()=>{const I=Array.from(E.options).find(N=>N.value===i||N.dataset.name===c);I?(E.value=I.value,u(I.value),t()):i?(S=i,b.textContent=c||i,d.textContent="-",v.textContent="-",t()):R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const x=[{unit:"caddy",label:"Caddy (reverse proxy)"},{unit:"dashcaddy-api",label:"DashCaddy API (host systemd unit, not this container)"},{unit:"docker",label:"Docker daemon"},{unit:"ssh",label:"SSH server"},{unit:"systemd-journald",label:"systemd-journald"},{unit:"tailscaled",label:"Tailscale"},{unit:"networkd-dispatcher",label:"Networkd dispatcher"}];injectModal("journald-modal",`
`}function l(i,c=""){if(!i||i.length===0){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',C.textContent="0 lines",M.textContent="0 filtered";return}if(O=i,m=c?i.filter(I=>I.text&&I.text.toLowerCase().includes(c.toLowerCase())):i,C.textContent=`${i.length} lines`,M.textContent=c?`${m.length} of ${i.length} shown`:`${i.length} shown`,m.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${o(c)}"</div>`;return}j.innerHTML=m.map((I,N)=>u(I,N)).join(""),j.scrollTop=j.scrollHeight}async function g(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],I=E.value;E.innerHTML='<option value="">Select a container...</option>',c.forEach(N=>{const U=document.createElement("option");U.value=N.id,U.textContent=`${N.name} (${N.image.split(":")[0]}) - ${N.status}`,U.dataset.name=N.name,U.dataset.image=N.image,U.dataset.status=N.status,U.dataset.created=N.created,E.appendChild(U)}),I&&E.querySelector(`option[value="${I}"]`)&&(E.value=I,p(I))}catch(i){console.error("Failed to load containers:",i)}}function p(i){const c=E.querySelector(`option[value="${i}"]`);c&&(b.textContent=c.dataset.image||"-",d.textContent=c.dataset.status||"-",d.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",v.textContent=L(c.dataset.created))}async function t(){const i=E.value;if(!i){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}s(),S=i,p(i);const c=P.value,I=h.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const N=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,U=await getJSON(N);U.logs&&U.logs.length>0?l(U.logs,I):(j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',C.textContent="0 lines",M.textContent="0 filtered")}catch(N){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${o(N.message)}</div>`}}function e(){const i=E.value;if(!i)return;s(),B=!0,D.textContent="\u23F9 Stop",H.style.display="flex",T.textContent="\u{1F7E2}",z.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;f=new EventSource(c),f.onopen=()=>{T.textContent="\u{1F7E2}",z.textContent="Connected - streaming logs"},f.onmessage=I=>{try{const N=JSON.parse(I.data);if(N.error){T.textContent="\u{1F534}",z.textContent=`Error: ${N.error}`;return}O.push(N),m.push(N),C.textContent=`${O.length} lines`,M.textContent=`${m.length} shown`;const U=h.value.trim();if(!U||N.text&&N.text.toLowerCase().includes(U.toLowerCase())){const _=document.createElement("div");_.innerHTML=u(N,m.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",j.appendChild(F),j.scrollTop=j.scrollHeight}}catch(N){console.error("Error parsing log:",N)}},f.onerror=()=>{T.textContent="\u{1F534}",z.textContent="Disconnected",B=!1,D.textContent="\u25B6 Stream"},x._eventSource=f}function s(){f&&(f.close(),f=null),x._eventSource&&(x._eventSource.close(),x._eventSource=null),B=!1,D.textContent="\u25B6 Stream",H.style.display="none"}function n(){if(!O||O.length===0){showNotification("No logs to download","error");return}const i=E.querySelector(`option[value="${S}"]`)?.dataset.name||S,c=new Date().toISOString().replace(/[:.]/g,"-"),I=`${i}-logs-${c}.txt`,N=O.map(q=>{const J=q.timestamp||"",X=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${q.text}`}).join(`
`),U=new Blob([N],{type:"text/plain"}),_=URL.createObjectURL(U),F=document.createElement("a");F.href=_,F.download=I,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${O.length} log lines`,"success")}E?.addEventListener("change",()=>{t()}),P?.addEventListener("change",()=>{t()}),A?.addEventListener("click",()=>{t()}),D?.addEventListener("click",()=>{B?s():e()}),R?.addEventListener("click",()=>{n()}),y?.addEventListener("click",()=>{h.value="",l(O,"")}),h?.addEventListener("input",()=>{clearTimeout(w),w=setTimeout(()=>{l(O,h.value.trim())},300)}),h?.addEventListener("keydown",i=>{i.key==="Escape"&&(h.value="",l(O,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{x.classList.add("show"),g()});function a(){s(),x.classList.remove("show")}$?.addEventListener("click",a),k?.addEventListener("click",a),document.addEventListener("keydown",i=>{i.key==="Escape"&&x.classList.contains("show")&&a()}),x.addEventListener("click",i=>{i.target===x&&a()}),window.openContainerLogsModal=function(i,c){x.classList.add("show"),g().then(()=>{const I=Array.from(E.options).find(N=>N.value===i||N.dataset.name===c);I?(E.value=I.value,p(I.value),t()):i?(S=i,b.textContent=c||i,d.textContent="-",v.textContent="-",t()):j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const x=[{unit:"caddy",label:"Caddy (reverse proxy)"},{unit:"dashcaddy-api",label:"DashCaddy API (host systemd unit, not this container)"},{unit:"docker",label:"Docker daemon"},{unit:"ssh",label:"SSH server"},{unit:"systemd-journald",label:"systemd-journald"},{unit:"tailscaled",label:"Tailscale"},{unit:"networkd-dispatcher",label:"Networkd dispatcher"}];injectModal("journald-modal",`
<div id="journald-modal" class="weather-modal" style="z-index: 1002;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
@@ -698,7 +698,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>
</div>
</div>
`);const E=document.getElementById("journald-modal"),R=document.getElementById("jd-unit-select"),h=document.getElementById("jd-search"),P=document.getElementById("jd-tail"),A=document.getElementById("jd-refresh"),D=document.getElementById("jd-stream"),j=document.getElementById("jd-clear-search"),g=document.getElementById("jd-close"),$=document.getElementById("jd-close-btn"),k=document.getElementById("jd-content"),M=document.getElementById("jd-line-count"),T=document.getElementById("jd-filter-count"),z=document.getElementById("jd-overflow"),C=document.getElementById("jd-unit-display"),H=document.getElementById("jd-stream-state");let b=!1,d=[],v=!1,S=null,O=null;function m(u){const t=document.createElement("div");return t.textContent=String(u),t.innerHTML}function f(u){b=u,R.innerHTML="",x.forEach(t=>{const e=document.createElement("option");e.value=t.unit,e.textContent=t.label+" ("+t.unit+")",R.appendChild(e)}),R.disabled=!u,u?(A.disabled=!1,D.disabled=!1):(k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>',A.disabled=!0,D.disabled=!0)}async function B(){try{const u=await fetch("/api/v1/logs/journal/units");if(!u.ok){f(!1);return}const t=await u.json();f(!!t.available)}catch{f(!1)}}function w(){const u=(h.value||"").trim().toLowerCase(),t=u?d.filter(e=>(e.textContent||"").toLowerCase().includes(u)):d;if(t.length===0)k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(u?` matching &quot;${m(u)}&quot;`:"")+"</div>";else{const e=t.map(n=>{const r=n.timestamp?m(n.timestamp):"\u2014",a=m(n.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${r}</span>${a}</div>`}).join("");k.innerHTML=e,k.scrollHeight-k.scrollTop-k.clientHeight<80&&(k.scrollTop=k.scrollHeight)}M.textContent=`${d.length} entries`,T.textContent=u?`${t.length} of ${d.length} shown`:`${d.length} shown`}async function L(){if(!b)return;p();const u=R.value;if(!u)return;const t=Math.max(1,Math.min(5e3,Number(P.value)||200)),e=(h.value||"").trim();k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const s=new URL("/api/v1/logs/journal",window.location.origin);s.searchParams.set("unit",u),s.searchParams.set("tail",String(t)),e&&s.searchParams.set("search",e);const n=await fetch(s.toString()),r=await n.json();if(!n.ok||!r.success){k.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+m(r&&r.error||"HTTP "+n.status)+"</div>";return}C.textContent=u,d=(r.entries||[]).map(a=>({timestamp:a.timestamp,unit:a.unit,textContent:a.text||""})),z.style.display="none",w()}catch(s){k.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+m(s.message)+"</div>"}}function o(){if(!b)return;p();const u=R.value;if(!u)return;const t=(h.value||"").trim();C.textContent=u,D.textContent="\u23F8 Stop",D.classList.add("streaming"),H.textContent="streaming",H.style.color="var(--ok-fg, #4ade80)",d=[],w(),z.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",u),t&&e.searchParams.set("search",t),S=new EventSource(e.toString()),S.onmessage=s=>{try{const n=JSON.parse(s.data);if(n.error){/stream (exceeded|line cap)/.test(n.error)&&(z.style.display="",p()),k.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+m(n.error)+"</div>",k.scrollTop=k.scrollHeight;return}d.push({timestamp:n.timestamp,unit:n.unit||u,textContent:n.text||""}),d.length>5e3&&(d=d.slice(d.length-5e3),z.style.display=""),w()}catch{}},S.onerror=()=>{},v=!0}function p(){if(v=!1,S){try{S.close()}catch{}S=null}D.textContent="\u25B6 Stream",D.classList.remove("streaming"),H.textContent="disconnected",H.style.color="var(--muted)"}function l(){p(),E.classList.remove("show")}A.addEventListener("click",L),D.addEventListener("click",()=>v?p():o()),j.addEventListener("click",()=>{h.value="",w()}),h.addEventListener("input",()=>{clearTimeout(O),O=setTimeout(w,200)}),h.addEventListener("keydown",u=>{u.key==="Escape"&&(h.value="",w())}),g.addEventListener("click",l),$.addEventListener("click",l),E.addEventListener("click",u=>{u.target===E&&l()}),document.addEventListener("keydown",u=>{u.key==="Escape"&&E.classList.contains("show")&&l()}),R.addEventListener("change",()=>{d.length>0&&L()});function y(){E.classList.add("show"),B()}window.openJournaldModal=y,document.getElementById("view-journald-logs")?.addEventListener("click",y)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
`);const E=document.getElementById("journald-modal"),j=document.getElementById("jd-unit-select"),h=document.getElementById("jd-search"),P=document.getElementById("jd-tail"),A=document.getElementById("jd-refresh"),D=document.getElementById("jd-stream"),R=document.getElementById("jd-clear-search"),y=document.getElementById("jd-close"),$=document.getElementById("jd-close-btn"),k=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),T=document.getElementById("jd-filter-count"),z=document.getElementById("jd-overflow"),C=document.getElementById("jd-unit-display"),M=document.getElementById("jd-stream-state");let b=!1,d=[],v=!1,S=null,O=null;function m(p){const t=document.createElement("div");return t.textContent=String(p),t.innerHTML}function f(p){b=p,j.innerHTML="",x.forEach(t=>{const e=document.createElement("option");e.value=t.unit,e.textContent=t.label+" ("+t.unit+")",j.appendChild(e)}),j.disabled=!p,p?(A.disabled=!1,D.disabled=!1):(k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>',A.disabled=!0,D.disabled=!0)}async function B(){try{const p=await fetch("/api/v1/logs/journal/units");if(!p.ok){f(!1);return}const t=await p.json();f(!!t.available)}catch{f(!1)}}function w(){const p=(h.value||"").trim().toLowerCase(),t=p?d.filter(e=>(e.textContent||"").toLowerCase().includes(p)):d;if(t.length===0)k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(p?` matching &quot;${m(p)}&quot;`:"")+"</div>";else{const e=t.map(n=>{const r=n.timestamp?m(n.timestamp):"\u2014",a=m(n.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${r}</span>${a}</div>`}).join("");k.innerHTML=e,k.scrollHeight-k.scrollTop-k.clientHeight<80&&(k.scrollTop=k.scrollHeight)}H.textContent=`${d.length} entries`,T.textContent=p?`${t.length} of ${d.length} shown`:`${d.length} shown`}async function L(){if(!b)return;u();const p=j.value;if(!p)return;const t=Math.max(1,Math.min(5e3,Number(P.value)||200)),e=(h.value||"").trim();k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const s=new URL("/api/v1/logs/journal",window.location.origin);s.searchParams.set("unit",p),s.searchParams.set("tail",String(t)),e&&s.searchParams.set("search",e);const n=await fetch(s.toString()),r=await n.json();if(!n.ok||!r.success){k.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+m(r&&r.error||"HTTP "+n.status)+"</div>";return}C.textContent=p,d=(r.entries||[]).map(a=>({timestamp:a.timestamp,unit:a.unit,textContent:a.text||""})),z.style.display="none",w()}catch(s){k.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+m(s.message)+"</div>"}}function o(){if(!b)return;u();const p=j.value;if(!p)return;const t=(h.value||"").trim();C.textContent=p,D.textContent="\u23F8 Stop",D.classList.add("streaming"),M.textContent="streaming",M.style.color="var(--ok-fg, #4ade80)",d=[],w(),z.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",p),t&&e.searchParams.set("search",t),S=new EventSource(e.toString()),S.onmessage=s=>{try{const n=JSON.parse(s.data);if(n.error){/stream (exceeded|line cap)/.test(n.error)&&(z.style.display="",u()),k.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+m(n.error)+"</div>",k.scrollTop=k.scrollHeight;return}d.push({timestamp:n.timestamp,unit:n.unit||p,textContent:n.text||""}),d.length>5e3&&(d=d.slice(d.length-5e3),z.style.display=""),w()}catch{}},S.onerror=()=>{},v=!0}function u(){if(v=!1,S){try{S.close()}catch{}S=null}D.textContent="\u25B6 Stream",D.classList.remove("streaming"),M.textContent="disconnected",M.style.color="var(--muted)"}function l(){u(),E.classList.remove("show")}A.addEventListener("click",L),D.addEventListener("click",()=>v?u():o()),R.addEventListener("click",()=>{h.value="",w()}),h.addEventListener("input",()=>{clearTimeout(O),O=setTimeout(w,200)}),h.addEventListener("keydown",p=>{p.key==="Escape"&&(h.value="",w())}),y.addEventListener("click",l),$.addEventListener("click",l),E.addEventListener("click",p=>{p.target===E&&l()}),document.addEventListener("keydown",p=>{p.key==="Escape"&&E.classList.contains("show")&&l()}),j.addEventListener("change",()=>{d.length>0&&L()});function g(){E.classList.add("show"),B()}window.openJournaldModal=g,document.getElementById("view-journald-logs")?.addEventListener("click",g)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>\u{1F4BE} Container Snapshots</h3>
<p class="modal-subtitle">
@@ -752,7 +752,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),R=document.getElementById("snapshot-close"),h=document.getElementById("snapshot-container-select"),P=document.getElementById("snapshot-details"),A=document.getElementById("snapshot-create-btn"),D=document.getElementById("snapshot-create-status");let j=null;async function g(){try{const C=await(await fetch("/api/v1/containers")).json();if(!C.success||!C.containers)return;h.innerHTML='<option value="">-- Select a container --</option>';for(const H of C.containers){const b=document.createElement("option");b.value=H.id,b.textContent=`${H.name||H.id} (${H.image||"unknown"})`,b.dataset.name=H.name,b.dataset.image=H.image,b.dataset.status=H.status,b.dataset.created=H.created,h.appendChild(b)}}catch(z){console.error("Failed to load containers:",z)}}function $(z){if(!z||!z.value){P.style.display="none",j=null;return}j=z.value,document.getElementById("snapshot-image").textContent=z.dataset.image||"-",document.getElementById("snapshot-status").textContent=z.dataset.status||"-",document.getElementById("snapshot-created").textContent=z.dataset.created?new Date(z.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=z.value.substring(0,12),P.style.display=""}async function k(){if(!j){D.textContent="Please select a container first",D.style.color="var(--bad-fg)";return}const z=document.getElementById("snapshot-name").value.trim();if(!z){D.textContent="Please enter a snapshot name",D.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-leave-running").checked;A.disabled=!0,A.textContent="Creating...",D.textContent="";try{const b=await(await fetch(`/api/v1/containers/${encodeURIComponent(j)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:z,leaveRunning:C})})).json();b.success?(D.textContent=`\u2713 Snapshot "${z}" created successfully`,D.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(D.textContent=`\u2717 Failed: ${b.error||"Unknown error"}`,D.style.color="var(--bad-fg)")}catch(H){D.textContent=`\u2717 Error: ${H.message}`,D.style.color="var(--bad-fg)"}finally{A.disabled=!1,A.textContent="\u{1F4BE} Create Snapshot"}}function M(){x.classList.add("show"),g()}function T(){x.classList.remove("show"),P.style.display="none",j=null,h.selectedIndex=0}E?.addEventListener("click",M),R?.addEventListener("click",T),wireModal(x,R),h?.addEventListener("change",z=>{const C=h.options[h.selectedIndex];$(C)}),A?.addEventListener("click",k),x?.querySelectorAll(".panel-tab").forEach(z=>{z.addEventListener("click",()=>{x.querySelectorAll(".panel-tab").forEach(C=>C.classList.remove("active")),x.querySelectorAll(".panel-section").forEach(C=>C.classList.remove("active")),z.classList.add("active"),x.querySelector(`#${z.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
</div>`);const x=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),j=document.getElementById("snapshot-close"),h=document.getElementById("snapshot-container-select"),P=document.getElementById("snapshot-details"),A=document.getElementById("snapshot-create-btn"),D=document.getElementById("snapshot-create-status");let R=null;async function y(){try{const C=await(await fetch("/api/v1/containers")).json();if(!C.success||!C.containers)return;h.innerHTML='<option value="">-- Select a container --</option>';for(const M of C.containers){const b=document.createElement("option");b.value=M.id,b.textContent=`${M.name||M.id} (${M.image||"unknown"})`,b.dataset.name=M.name,b.dataset.image=M.image,b.dataset.status=M.status,b.dataset.created=M.created,h.appendChild(b)}}catch(z){console.error("Failed to load containers:",z)}}function $(z){if(!z||!z.value){P.style.display="none",R=null;return}R=z.value,document.getElementById("snapshot-image").textContent=z.dataset.image||"-",document.getElementById("snapshot-status").textContent=z.dataset.status||"-",document.getElementById("snapshot-created").textContent=z.dataset.created?new Date(z.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=z.value.substring(0,12),P.style.display=""}async function k(){if(!R){D.textContent="Please select a container first",D.style.color="var(--bad-fg)";return}const z=document.getElementById("snapshot-name").value.trim();if(!z){D.textContent="Please enter a snapshot name",D.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-leave-running").checked;A.disabled=!0,A.textContent="Creating...",D.textContent="";try{const b=await(await fetch(`/api/v1/containers/${encodeURIComponent(R)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:z,leaveRunning:C})})).json();b.success?(D.textContent=`\u2713 Snapshot "${z}" created successfully`,D.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(D.textContent=`\u2717 Failed: ${b.error||"Unknown error"}`,D.style.color="var(--bad-fg)")}catch(M){D.textContent=`\u2717 Error: ${M.message}`,D.style.color="var(--bad-fg)"}finally{A.disabled=!1,A.textContent="\u{1F4BE} Create Snapshot"}}function H(){x.classList.add("show"),y()}function T(){x.classList.remove("show"),P.style.display="none",R=null,h.selectedIndex=0}E?.addEventListener("click",H),j?.addEventListener("click",T),wireModal(x,j),h?.addEventListener("change",z=>{const C=h.options[h.selectedIndex];$(C)}),A?.addEventListener("click",k),x?.querySelectorAll(".panel-tab").forEach(z=>{z.addEventListener("click",()=>{x.querySelectorAll(".panel-tab").forEach(C=>C.classList.remove("active")),x.querySelectorAll(".panel-section").forEach(C=>C.classList.remove("active")),z.classList.add("active"),x.querySelector(`#${z.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -831,10 +831,10 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),R=document.getElementById("arr-setup-cancel"),h=document.getElementById("smart-connect-btn"),P=document.getElementById("smart-phase-detect"),A=document.getElementById("smart-phase-credentials"),D=document.getElementById("smart-phase-progress"),j=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),$=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),T=document.getElementById("smart-plex-libraries"),z=document.getElementById("smart-retry-btn");let C=null;const H={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},b={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function d(w){P.style.display=w==="detect"?"block":"none",A.style.display=w==="credentials"?"block":"none",D.style.display=w==="progress"?"block":"none",j.style.display=w==="results"?"block":"none"}function v(w){const L={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},o=L[w]||L.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${o.bg} 20%, transparent); color: ${o.bg};">${o.icon} ${o.text}</span>`}async function S(){d("detect"),g.style.display="none";try{if(C=await(await fetch("/api/v1/arr/smart-detect")).json(),!C.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(C.error)}</div>`,g.style.display="block";return}let L='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[p,l]of Object.entries(C.services)){const y=H[p]||"\u{1F4E6}",u=b[p]||p,t=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",s=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";L+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${y}</span>
</div>`);const x=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),j=document.getElementById("arr-setup-cancel"),h=document.getElementById("smart-connect-btn"),P=document.getElementById("smart-phase-detect"),A=document.getElementById("smart-phase-credentials"),D=document.getElementById("smart-phase-progress"),R=document.getElementById("smart-phase-results"),y=document.getElementById("smart-detect-results"),$=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),T=document.getElementById("smart-plex-libraries"),z=document.getElementById("smart-retry-btn");let C=null;const M={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},b={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function d(w){P.style.display=w==="detect"?"block":"none",A.style.display=w==="credentials"?"block":"none",D.style.display=w==="progress"?"block":"none",R.style.display=w==="results"?"block":"none"}function v(w){const L={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},o=L[w]||L.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${o.bg} 20%, transparent); color: ${o.bg};">${o.icon} ${o.text}</span>`}async function S(){d("detect"),y.style.display="none";try{if(C=await(await fetch("/api/v1/arr/smart-detect")).json(),!C.success){y.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(C.error)}</div>`,y.style.display="block";return}let L='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[u,l]of Object.entries(C.services)){const g=M[u]||"\u{1F4E6}",p=b[u]||u,t=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",s=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";L+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${g}</span>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
<div style="font-weight: 500; font-size: 0.9rem;">${p}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${t} ${e} ${s}
</div>
@@ -843,61 +843,61 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>`}L+="</div>";const o=C.summary;L+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(o.fullyConnected))}/${escapeHtml(String(o.totalDetected+(5-o.totalDetected)))} services detected &middot;
${escapeHtml(String(o.fullyConnected))} connected${o.needsApiKey>0?` &middot; <strong>${escapeHtml(String(o.needsApiKey))} needs API key</strong>`:""}
</div>`,g.innerHTML=L,g.style.display="block",O(C),setTimeout(()=>{d("credentials")},800)}catch(w){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(w.message)}</div>`,g.style.display="block"}}function O(w){let L="";const o=w.services,p=["radarr","sonarr","prowlarr"];for(const u of p){const t=o[u];if(!t||t.status==="not_found"&&!t.url)continue;const e=H[u],s=b[u],n=t.status==="connected";L+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${n?"var(--ok-fg)":"var(--border)"};">
</div>`,y.innerHTML=L,y.style.display="block",O(C),setTimeout(()=>{d("credentials")},800)}catch(w){y.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(w.message)}</div>`,y.style.display="block"}}function O(w){let L="";const o=w.services,u=["radarr","sonarr","prowlarr"];for(const p of u){const t=o[p];if(!t||t.status==="not_found"&&!t.url)continue;const e=M[p],s=b[p],n=t.status==="connected";L+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${n?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${e}</span>
<span style="font-weight: 500;">${s}</span>
<span id="smart-${u}-status" style="margin-left: auto; font-size: 0.75rem;">
<span id="smart-${p}-status" style="margin-left: auto; font-size: 0.75rem;">
${n?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
<input type="text" id="smart-${u}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${u}/"
<input type="text" id="smart-${p}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${p}/"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${u}-key" placeholder="${n?"(saved)":"Paste API key"}"
<input type="password" id="smart-${p}-key" placeholder="${n?"(saved)":"Paste API key"}"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
</div>
<button onclick="smartTestConnection('${u}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const l=o.plex;if(l){const u=l.status==="connected";L+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<button onclick="smartTestConnection('${p}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const l=o.plex;if(l){const p=l.status==="connected";L+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span>
${v(l.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(l.source||"")}</span>
</div>
</div>`}const y=o.seerr;if(y){const u=y.status==="connected";let t="";if(y.configuredServices){const e=y.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
</div>`}const g=o.seerr;if(g){const p=g.status==="connected";let t="";if(g.configuredServices){const e=g.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${e.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}L+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"};">
</div>`}L+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span>
${v(y.status)}
${v(g.status)}
</div>
${t}
</div>`}$.innerHTML=L}window.smartTestConnection=async function(w){const L=document.getElementById(`smart-${w}-url`),o=document.getElementById(`smart-${w}-key`),p=document.getElementById(`smart-${w}-status`),l=L?.value.trim(),y=o?.value.trim();if(!l||!y){p.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}p.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:w,url:l,apiKey:y})})).json();t.success?p.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(u){p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(u.message)}</span>`}};async function m(){d("progress"),k.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const w={};for(const o of["radarr","sonarr","prowlarr"]){const p=document.getElementById(`smart-${o}-url`)?.value.trim(),l=document.getElementById(`smart-${o}-key`)?.value.trim();l&&p?w[o]={apiKey:l,url:p}:l&&(w[o]={apiKey:l})}const L={services:Object.keys(w).length>0?w:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const p=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(L)})).json();let l="";for(const y of p.steps||[]){const u=y.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=y.status==="success"?"var(--muted)":"var(--bad-fg)";l+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${u}
<span>${escapeHtml(y.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(y.details||"")}</span>
</div>`}k.innerHTML=l,setTimeout(()=>f(p),500)}catch(o){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(o.message)}</div>`}}function f(w){d("results");const L=w.summary||{},o=L.failed===0&&L.succeeded>0,p=o?"var(--ok-fg)":"#f39c12",l=o?"&#10003;":"&#9888;",y=o?"All Connected!":`${escapeHtml(String(L.succeeded))}/${escapeHtml(String(L.totalSteps))} Steps Succeeded`;let u=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${p} 12%, transparent); border-radius: 10px; border: 1px solid ${p}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${p};">${l}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${p};">${y}</div>
</div>`}$.innerHTML=L}window.smartTestConnection=async function(w){const L=document.getElementById(`smart-${w}-url`),o=document.getElementById(`smart-${w}-key`),u=document.getElementById(`smart-${w}-status`),l=L?.value.trim(),g=o?.value.trim();if(!l||!g){u.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}u.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:w,url:l,apiKey:g})})).json();t.success?u.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:u.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(p){u.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(p.message)}</span>`}};async function m(){d("progress"),k.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const w={};for(const o of["radarr","sonarr","prowlarr"]){const u=document.getElementById(`smart-${o}-url`)?.value.trim(),l=document.getElementById(`smart-${o}-key`)?.value.trim();l&&u?w[o]={apiKey:l,url:u}:l&&(w[o]={apiKey:l})}const L={services:Object.keys(w).length>0?w: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 u=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(L)})).json();let l="";for(const g of u.steps||[]){const p=g.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=g.status==="success"?"var(--muted)":"var(--bad-fg)";l+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${p}
<span>${escapeHtml(g.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(g.details||"")}</span>
</div>`}k.innerHTML=l,setTimeout(()=>f(u),500)}catch(o){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(o.message)}</div>`}}function f(w){d("results");const L=w.summary||{},o=L.failed===0&&L.succeeded>0,u=o?"var(--ok-fg)":"#f39c12",l=o?"&#10003;":"&#9888;",g=o?"All Connected!":`${escapeHtml(String(L.succeeded))}/${escapeHtml(String(L.totalSteps))} Steps Succeeded`;let p=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${u} 12%, transparent); border-radius: 10px; border: 1px solid ${u}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${u};">${l}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${u};">${g}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(L.succeeded))} succeeded, ${escapeHtml(String(L.failed))} failed</div>
</div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of w.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';u+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
</div>`;p+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of w.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';p+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
</div>`}u+="</div>",M.innerHTML=u,z.style.display=L.failed>0?"block":"none",w.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&B()}async function B(){try{const L=await(await fetch("/api/v1/plex/libraries")).json();if(L.success&&L.libraries?.length>0){let o=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
</div>`}p+="</div>",H.innerHTML=p,z.style.display=L.failed>0?"block":"none",w.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&B()}async function B(){try{const L=await(await fetch("/api/v1/plex/libraries")).json();if(L.success&&L.libraries?.length>0){let o=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(L.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const p of L.libraries){const l=p.type==="movie"?"\u{1F3AC}":p.type==="show"?"\u{1F4FA}":"\u{1F3B5}";o+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${l} <strong>${escapeHtml(p.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(p.count))} items</span>
</div>`}o+="</div></div>",T.innerHTML=o,T.style.display="block"}}catch{}}E?.addEventListener("click",()=>{x.classList.add("show"),T.style.display="none",S()}),wireModal(x,R),h?.addEventListener("click",m),z?.addEventListener("click",m)})(),(function(){const x=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const u of L.libraries){const l=u.type==="movie"?"\u{1F3AC}":u.type==="show"?"\u{1F4FA}":"\u{1F3B5}";o+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${l} <strong>${escapeHtml(u.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(u.count))} items</span>
</div>`}o+="</div></div>",T.innerHTML=o,T.style.display="block"}}catch{}}E?.addEventListener("click",()=>{x.classList.add("show"),T.style.display="none",S()}),wireModal(x,j),h?.addEventListener("click",m),z?.addEventListener("click",m)})(),(function(){const x=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3>
@@ -1109,15 +1109,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="notifications-save" class="btn-accent">Save Settings</button>
</div>
</div>
</div>`);const E=document.getElementById("notifications-modal"),R=document.getElementById("manage-notifications"),h=document.getElementById("notifications-save"),P=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(T=>{const z=document.getElementById(`${T}-enabled`),C=document.getElementById(`${T}-config`);z?.addEventListener("change",()=>{C.style.display=z.checked?"block":"none"})});const A=document.getElementById("health-check-enabled"),D=document.getElementById("health-check-config");A?.addEventListener("change",()=>{D.style.opacity=A.checked?"1":"0.5"});async function j(){try{const z=await(await fetch("/api/v1/notifications/config")).json();if(z.success){const C=z.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),C.providers?.email?.to&&(document.getElementById("email-to").value=C.providers.email.to),C.providers?.email?.port&&(document.getElementById("email-port").value=C.providers.email.port),C.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=C.providers.email.secure===!0),C.providers?.email?.username&&(document.getElementById("email-user").value=C.providers.email.username);const H=document.getElementById("email-pass");C.providers?.email?.hasPassword?(H.value="",H.placeholder="saved \u2014 leave blank to keep"):H.placeholder="app password",document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=C.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=C.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=C.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=C.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=C.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=C.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=C.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=C.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=C.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=C.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=C.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=C.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=C.events?.alert!==!1}}catch(T){x.logError("[Notifications] Load Config",T,{function:"loadConfig"})}}async function g(){try{const z=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");z.success&&z.history?.length>0?C.innerHTML=z.history.map(H=>{const b=new Date(H.timestamp).toLocaleString();return`
</div>`);const E=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),h=document.getElementById("notifications-save"),P=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(T=>{const z=document.getElementById(`${T}-enabled`),C=document.getElementById(`${T}-config`);z?.addEventListener("change",()=>{C.style.display=z.checked?"block":"none"})});const A=document.getElementById("health-check-enabled"),D=document.getElementById("health-check-config");A?.addEventListener("change",()=>{D.style.opacity=A.checked?"1":"0.5"});async function R(){try{const z=await(await fetch("/api/v1/notifications/config")).json();if(z.success){const C=z.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),C.providers?.email?.to&&(document.getElementById("email-to").value=C.providers.email.to),C.providers?.email?.port&&(document.getElementById("email-port").value=C.providers.email.port),C.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=C.providers.email.secure===!0),C.providers?.email?.username&&(document.getElementById("email-user").value=C.providers.email.username);const M=document.getElementById("email-pass");C.providers?.email?.hasPassword?(M.value="",M.placeholder="saved \u2014 leave blank to keep"):M.placeholder="app password",document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=C.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=C.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=C.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=C.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=C.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=C.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=C.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=C.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=C.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=C.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=C.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=C.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=C.events?.alert!==!1}}catch(T){x.logError("[Notifications] Load Config",T,{function:"loadConfig"})}}async function y(){try{const z=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");z.success&&z.history?.length>0?C.innerHTML=z.history.map(M=>{const b=new Date(M.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[H.type]||"var(--muted)"}">${H.type==="success"?"\u2713":H.type==="error"?"\u2717":"\u2139"}</span>
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[M.type]||"var(--muted)"}">${M.type==="success"?"\u2713":M.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(H.title)}</div>
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(M.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${b}</div>
</div>
</div>
`}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(T){x.logError("[Notifications] Load History",T,{function:"loadHistory"})}}async function $(){try{const T={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(T)})).json();C.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(T){showNotification(`Error: ${T.message}`,"error",3e3)}}async function k(T){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:T})})).json();C.success?showNotification(`Test ${T} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch(z){showNotification(`Error: ${z.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const z=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();z.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(z.lastCheck).toLocaleString()} (${z.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(T){showNotification(`Error: ${T.message}`,"error",3e3)}}),R?.addEventListener("click",()=>{E.classList.add("show"),j(),g()}),h?.addEventListener("click",$),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const T=document.getElementById("notifications-send-test"),z=T.textContent;T.textContent="Sending...",T.disabled=!0;try{const H=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();H.success?(showNotification("Test notification sent!","success",3e3),M()):showNotification(`Test failed: ${H.results?.map(b=>`${b.provider}: ${b.error||"ok"}`).join(", ")}`,"error",5e3)}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}finally{T.textContent=z,T.disabled=!1}});async function M(){try{const z=await(await fetch("/api/v1/notifications/status")).json();if(z.success&&z.lastSent){ Line truncated
`}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(T){x.logError("[Notifications] Load History",T,{function:"loadHistory"})}}async function $(){try{const T={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(T)})).json();C.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(T){showNotification(`Error: ${T.message}`,"error",3e3)}}async function k(T){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:T})})).json();C.success?showNotification(`Test ${T} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch(z){showNotification(`Error: ${z.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const z=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();z.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(z.lastCheck).toLocaleString()} (${z.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(T){showNotification(`Error: ${T.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{E.classList.add("show"),R(),y()}),h?.addEventListener("click",$),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const T=document.getElementById("notifications-send-test"),z=T.textContent;T.textContent="Sending...",T.disabled=!0;try{const M=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();M.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${M.results?.map(b=>`${b.provider}: ${b.error||"ok"}`).join(", ")}`,"error",5e3)}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}finally{T.textContent=z,T.disabled=!1}});async function H(){try{const z=await(await fetch("/api/v1/notifications/status")).json();if(z.success&&z.lastSent){ Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle">
@@ -1223,7 +1223,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button>
</div>
</div>
</div>`);var A=document.getElementById("backup-modal"),D=document.getElementById("backup-restore-btn"),j=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),$=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),T=document.getElementById("backup-preview"),z=document.getElementById("backup-preview-content"),C=document.getElementById("backup-do-restore-btn"),H=document.getElementById("backup-result"),b=document.getElementById("backup-schedules-container"),d=document.getElementById("backup-history-container"),v=document.getElementById("backup-disk-container"),S=document.getElementById("pointintime-container"),O=null;D?.addEventListener("click",function(){A.classList.add("show"),H&&(H.style.display="none"),T&&(T.style.display="none"),M&&(M.style.display="none"),O=null}),wireModal(A,j),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),s=await e.json();s.browserState=E();var n=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),r=URL.createObjectURL(n),a=document.createElement("a");a.href=r,a.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(r);var i=Object.keys(s.browserState).length,c=s.themes?Object.keys(s.themes).length:0;H.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),H.style.display="block",H.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",H.style.border="1px solid var(--ok-fg)"}catch(I){H.innerHTML="\u274C Export failed: "+escapeHtml(I.message),H.style.display="block",H.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",H.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),$?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var s=e.target.files[0];if(s){M.textContent="\u{1F4C4} "+s.name,M.style.display="block",H.style.display="none";try{var n=await s.text(),r=JSON.parse(n);if(h(r)){O=r;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(r.version)+")</div>";a+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',r.services?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+r.services.length+" services</span>"),r.customApps?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+r.customApps.length+" custom apps</span>"),r.theme&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(r.theme)+"</span>"),r.userThemes&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(r.userThemes).length+" custom themes</span>"),a+="</div>",z.innerHTML=a,T.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),c=await i.json();if(c.success){O=r;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(r.exportedAt).toLocaleString()+" (v"+escapeHtml(r.version)+")</div>";a+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',a+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var I in c.preview.files){var N=c.preview.files[I],U=N.action==="create"?"\u{1F195}":"\u{1F4DD}";a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(N.description)+"</span>"}a+="</div>",c.preview.serviceCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+c.preview.serviceCount+" services</div>"),c.preview.themeCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+c.preview.themeCount+" custom themes</div>"),c.preview.browserStateCount&&(a+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',a+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),z.innerHTML=a,T.style.display="block"}else H.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),H.style.display="block",H.style.background="color-mix(in srgb, #f39c12 15%, transparent)",H.style.border="1px solid #f39c12",T.style.display="none"}catch(_){H.innerHTML="\u274CLine truncated
</div>`);var A=document.getElementById("backup-modal"),D=document.getElementById("backup-restore-btn"),R=document.getElementById("backup-cancel"),y=document.getElementById("backup-export-btn"),$=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),T=document.getElementById("backup-preview"),z=document.getElementById("backup-preview-content"),C=document.getElementById("backup-do-restore-btn"),M=document.getElementById("backup-result"),b=document.getElementById("backup-schedules-container"),d=document.getElementById("backup-history-container"),v=document.getElementById("backup-disk-container"),S=document.getElementById("pointintime-container"),O=null;D?.addEventListener("click",function(){A.classList.add("show"),M&&(M.style.display="none"),T&&(T.style.display="none"),H&&(H.style.display="none"),O=null}),wireModal(A,R),y?.addEventListener("click",async function(){y.disabled=!0,y.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),s=await e.json();s.browserState=E();var n=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),r=URL.createObjectURL(n),a=document.createElement("a");a.href=r,a.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(r);var i=Object.keys(s.browserState).length,c=s.themes?Object.keys(s.themes).length:0;M.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),M.style.display="block",M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)"}catch(I){M.innerHTML="\u274C Export failed: "+escapeHtml(I.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)"}y.disabled=!1,y.innerHTML="\u2B07\uFE0F Download Full Backup"}),$?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var s=e.target.files[0];if(s){H.textContent="\u{1F4C4} "+s.name,H.style.display="block",M.style.display="none";try{var n=await s.text(),r=JSON.parse(n);if(h(r)){O=r;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(r.version)+")</div>";a+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',r.services?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+r.services.length+" services</span>"),r.customApps?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+r.customApps.length+" custom apps</span>"),r.theme&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(r.theme)+"</span>"),r.userThemes&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(r.userThemes).length+" custom themes</span>"),a+="</div>",z.innerHTML=a,T.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),c=await i.json();if(c.success){O=r;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(r.exportedAt).toLocaleString()+" (v"+escapeHtml(r.version)+")</div>";a+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',a+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var I in c.preview.files){var N=c.preview.files[I],U=N.action==="create"?"\u{1F195}":"\u{1F4DD}";a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(N.description)+"</span>"}a+="</div>",c.preview.serviceCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+c.preview.serviceCount+" services</div>"),c.preview.themeCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+c.preview.themeCount+" custom themes</div>"),c.preview.browserStateCount&&(a+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',a+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),z.innerHTML=a,T.style.display="block"}else M.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),M.style.display="block",M.style.background="color-mix(in srgb, #f39c12 15%, transparent)",M.style.border="1px solid #f39c12",T.style.display="none"}catch(_){M.innerHTML="\u274CLine truncated
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var n=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(s),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),r=await n.json();r.success?(showNotification(e+" restored to "+s,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(r.error||"Unknown"),"error")}catch(a){showNotification("Restore error: "+a.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
@@ -1305,7 +1305,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),R=document.getElementById("stats-cancel"),h=document.getElementById("stats-refresh-btn"),P=document.getElementById("stats-auto-refresh"),A=document.getElementById("stats-container"),D=document.getElementById("stats-aggregated-container"),j=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let $=null,k=null;function M(l){if(l===0||!l)return"0 B";const y=1024,u=["B","KB","MB","GB"],t=Math.floor(Math.log(l)/Math.log(y));return parseFloat((l/Math.pow(y,t)).toFixed(1))+" "+u[t]}function T(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function z(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function C(){try{let l=null,y=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,y=!0,k=e.stats)}catch{}if(!y){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const s of e.stats)l[s.name]={name:s.name,current:{cpu:s.cpu,memory:{percent:s.memory.percent,usage:s.memory.used,limit:s.memory.limit,usageMB:Math.round(s.memory.used/1048576),limitMB:Math.round(s.memory.limit/1048576)},network:{rxBytes:s.network.rx,txBytes:s.network.tx,rxMB:(s.network.rx/1048576).toFixed(1),txMB:(s.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:s.status};k=l}}if(!l||Object.keys(l).length===0){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(l)){const s=e.current||e,n=s.cpu?.percent||0,r=s.memory?.percent||0,a=T(n),i=z(r),c=s.memory?.usage||s.memory?.used||0,I=s.memory?.limit||0,N=s.network?.rxBytes||s.network?.rx||0,U=s.network?.txBytes||s.network?.tx||0,_=e.aggregated;u+=`
</div>`);const x=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),h=document.getElementById("stats-refresh-btn"),P=document.getElementById("stats-auto-refresh"),A=document.getElementById("stats-container"),D=document.getElementById("stats-aggregated-container"),R=document.getElementById("stats-alerts-container"),y=document.getElementById("stats-last-update");let $=null,k=null;function H(l){if(l===0||!l)return"0 B";const g=1024,p=["B","KB","MB","GB"],t=Math.floor(Math.log(l)/Math.log(g));return parseFloat((l/Math.pow(g,t)).toFixed(1))+" "+p[t]}function T(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function z(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function C(){try{let l=null,g=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,g=!0,k=e.stats)}catch{}if(!g){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const s of e.stats)l[s.name]={name:s.name,current:{cpu:s.cpu,memory:{percent:s.memory.percent,usage:s.memory.used,limit:s.memory.limit,usageMB:Math.round(s.memory.used/1048576),limitMB:Math.round(s.memory.limit/1048576)},network:{rxBytes:s.network.rx,txBytes:s.network.tx,rxMB:(s.network.rx/1048576).toFixed(1),txMB:(s.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:s.status};k=l}}if(!l||Object.keys(l).length===0){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(l)){const s=e.current||e,n=s.cpu?.percent||0,r=s.memory?.percent||0,a=T(n),i=z(r),c=s.memory?.usage||s.memory?.used||0,I=s.memory?.limit||0,N=s.network?.rxBytes||s.network?.rx||0,U=s.network?.txBytes||s.network?.tx||0,_=e.aggregated;p+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
@@ -1330,19 +1330,19 @@ This will replace current configuration, credentials, and data. Containers will
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${r.toFixed(1)}%</span>
</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${M(c)} / ${M(I)}</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(c)} / ${H(I)}</div>
</div>
<div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${M(N)}</span>
<span style="color: #3498db;">\u2193 ${H(N)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${M(U)}</span>
<span style="color: #e74c3c;">\u2191 ${H(U)}</span>
</div>
</div>
</div>
</div>`}u+="</div>",A.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){A.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function H(){if(!D)return;const l=k;if(!l||Object.keys(l).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,t]of Object.entries(l)){const e=t.aggregated;e&&(y+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||u}</div>
</div>`}p+="</div>",A.innerHTML=p,y.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){A.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function M(){if(!D)return;const l=k;if(!l||Object.keys(l).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let g='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[p,t]of Object.entries(l)){const e=t.aggregated;e&&(g+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||p}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
@@ -1350,18 +1350,18 @@ This will replace current configuration, credentials, and data. Containers will
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div>
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
</div>`)}y+="</div>",D.innerHTML=y}async function b(){if(!j)return;j.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const l=k;if(!l||Object.keys(l).length===0){j.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let y=!1;try{y=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{y=!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 s=Object.entries(l).map(([a,i])=>{const c=t[a]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
</div>`)}g+="</div>",D.innerHTML=g}async function b(){if(!R)return;R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const l=k;if(!l||Object.keys(l).length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let g=!1;try{g=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{g=!1}let p=[];try{const i=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();i.success&&(p=i.history||[])}catch{}let t={};try{const i=await(await fetch("/api/v1/monitoring/alerts/config")).json();i.success&&(t=i.configs||{})}catch{}const s=Object.entries(l).map(([a,i])=>{const c=t[a]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
<tr data-container="${a}">
<td style="font-weight: 600; padding: 8px;">${i.name||a}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${c.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${y?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${c.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${y?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${c.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${y?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${c.autoRestart?"checked":""} ${y?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${c.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${c.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${c.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${c.autoRestart?"checked":""} ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${a}" data-name="${i.name||a}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
</td>
</tr>
`}).join(""),n=u.map(a=>{const i=new Date(a.timestamp).toLocaleString(),c=a.notified?"\u2713":"\u2014";return`
`}).join(""),n=p.map(a=>{const i=new Date(a.timestamp).toLocaleString(),c=a.notified?"\u2713":"\u2014";return`
<tr>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${i}</td>
<td style="padding: 6px 8px; font-weight: 500;">${a.containerName||a.containerId}</td>
@@ -1370,7 +1370,7 @@ This will replace current configuration, credentials, and data. Containers will
<td style="padding: 6px 8px; text-align: center;">${c}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${a.autoRestartTriggered?"#f39c12":"var(--muted)"};">${a.autoRestartTriggered?"\u21BB":""}</td>
</tr>
`}).join(""),r=y?`
`}).join(""),r=g?`
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
@@ -1401,7 +1401,7 @@ This will replace current configuration, credentials, and data. Containers will
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
</div>
`;j.innerHTML=`
`;R.innerHTML=`
${r}
<div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
@@ -1423,21 +1423,21 @@ This will replace current configuration, credentials, and data. Containers will
</div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const a={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;a[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:a})})).json(),I=document.getElementById("save-all-alerts");I.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{I.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",a=>{a.preventDefault(),x.classList.remove("show"),v(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(a=>{a.addEventListener("click",async()=>{const i=a.textContent;a.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${a.dataset.container}/test`,{method:"POST"}),a.textContent="\u2705",showNotification("Test alert sent for "+a.dataset.name,"success",3e3)}catch{a.textContent="\u274C"}setTimeout(()=>{a.textContent=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{x.classList.remove("show"),v(),typeof openLicenseModal=="function"&&openLicenseModal()})}function d(){$&&clearInterval($),P?.checked&&($=setInterval(C,DC.POLL.STATS))}function v(){$&&(clearInterval($),$=null)}E?.addEventListener("click",()=>{x.classList.add("show"),C(),d()}),R?.addEventListener("click",()=>{x.classList.remove("show"),v()}),x?.addEventListener("click",l=>{l.target===x&&(x.classList.remove("show"),v())}),h?.addEventListener("click",C),P?.addEventListener("change",()=>{P.checked?d():v()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",H),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",b);const S=document.getElementById("stats-history-container"),O=document.getElementById("stats-history-container-area"),m=document.querySelectorAll(".stats-range-btn");let f="1h";function B(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function w(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function L(l,y,u,t,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const s=l.map(y).filter(q=>q!=null);if(s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const n=Math.max(...s,1),r=Math.min(...s,0),a=n-r||1,i=600,c=80,I=4,N=(i-I*2)/Math.max(s.length-1,1),U=s.map((q,J)=>{const X=I+J*N,Q=c-I-(q-r)/a*(c-I*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=s[s.length-1],F=s.reduce((q,J)=>q+J,0)/s.length;return`
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const a={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;a[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:a})})).json(),I=document.getElementById("save-all-alerts");I.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{I.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",a=>{a.preventDefault(),x.classList.remove("show"),v(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(a=>{a.addEventListener("click",async()=>{const i=a.textContent;a.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${a.dataset.container}/test`,{method:"POST"}),a.textContent="\u2705",showNotification("Test alert sent for "+a.dataset.name,"success",3e3)}catch{a.textContent="\u274C"}setTimeout(()=>{a.textContent=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{x.classList.remove("show"),v(),typeof openLicenseModal=="function"&&openLicenseModal()})}function d(){$&&clearInterval($),P?.checked&&($=setInterval(C,DC.POLL.STATS))}function v(){$&&(clearInterval($),$=null)}E?.addEventListener("click",()=>{x.classList.add("show"),C(),d()}),j?.addEventListener("click",()=>{x.classList.remove("show"),v()}),x?.addEventListener("click",l=>{l.target===x&&(x.classList.remove("show"),v())}),h?.addEventListener("click",C),P?.addEventListener("change",()=>{P.checked?d():v()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",M),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",b);const S=document.getElementById("stats-history-container"),O=document.getElementById("stats-history-container-area"),m=document.querySelectorAll(".stats-range-btn");let f="1h";function B(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function w(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function L(l,g,p,t,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const s=l.map(g).filter(q=>q!=null);if(s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const n=Math.max(...s,1),r=Math.min(...s,0),a=n-r||1,i=600,c=80,I=4,N=(i-I*2)/Math.max(s.length-1,1),U=s.map((q,J)=>{const X=I+J*N,Q=c-I-(q-r)/a*(c-I*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=s[s.length-1],F=s.reduce((q,J)=>q+J,0)/s.length;return`
<div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${n.toFixed(1)}${e}</span>
</div>
<svg viewBox="0 0 ${i} ${c}" preserveAspectRatio="none" style="width: 100%; height: ${c}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${u}" stroke-width="1.5" points="${U}" />
<polyline fill="none" stroke="${p}" stroke-width="1.5" points="${U}" />
</svg>
</div>
`}function o(){if(!S)return;const l=k||{},y=S.value,u=Object.entries(l);if(u.length===0){S.innerHTML='<option value="">No containers</option>';return}S.innerHTML=u.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),y&&l[y]&&(S.value=y)}async function p(){if(!O||!S)return;const l=S.value;if(!l){O.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const y=Date.now(),u=y-B(f);O.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(l)}?startTime=${u}&endTime=${y}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const s=e.samples||[],n=e.tier||"raw";if(s.length===0){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${f}. Tier: ${w(n)}.</div>`;return}const r=n==="raw",a=r?U=>U.cpu?.percent:U=>U.cpu?.avg,i=r?U=>U.memory?.percent:U=>U.memory?.avgPercent,c=r?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,I=r?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let N=`
`}function o(){if(!S)return;const l=k||{},g=S.value,p=Object.entries(l);if(p.length===0){S.innerHTML='<option value="">No containers</option>';return}S.innerHTML=p.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),g&&l[g]&&(S.value=g)}async function u(){if(!O||!S)return;const l=S.value;if(!l){O.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const g=Date.now(),p=g-B(f);O.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(l)}?startTime=${p}&endTime=${g}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const s=e.samples||[],n=e.tier||"raw";if(s.length===0){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${f}. Tier: ${w(n)}.</div>`;return}const r=n==="raw",a=r?U=>U.cpu?.percent:U=>U.cpu?.avg,i=r?U=>U.memory?.percent:U=>U.memory?.avgPercent,c=r?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,I=r?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let N=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${s.length} samples \xB7 ${escapeHtml(w(n))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(y).toLocaleString()}
${s.length} samples \xB7 ${escapeHtml(w(n))} \xB7 ${new Date(p).toLocaleString()} \u2192 ${new Date(g).toLocaleString()}
</div>
`;N+=L(s,a,"#2ecc71","CPU","%"),N+=L(s,i,"#3498db","Memory","%"),N+=L(s,c,"#9b59b6","Network RX"," MB"),N+=L(s,I,"#e67e22","Network TX"," MB"),O.innerHTML=N}catch(t){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}m.forEach(l=>{l.addEventListener("click",()=>{m.forEach(y=>y.classList.remove("active")),l.classList.add("active"),f=l.dataset.range,p()})}),S?.addEventListener("change",p),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{o(),p()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
`;N+=L(s,a,"#2ecc71","CPU","%"),N+=L(s,i,"#3498db","Memory","%"),N+=L(s,c,"#9b59b6","Network RX"," MB"),N+=L(s,I,"#e67e22","Network TX"," MB"),O.innerHTML=N}catch(t){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}m.forEach(l=>{l.addEventListener("click",()=>{m.forEach(g=>g.classList.remove("active")),l.classList.add("active"),f=l.dataset.range,u()})}),S?.addEventListener("change",u),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{o(),u()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle">
@@ -1561,7 +1561,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),R=document.getElementById("health-cancel"),h=document.getElementById("health-refresh-btn"),P=document.getElementById("health-status-container"),A=document.getElementById("health-incidents-container"),D=document.getElementById("health-config-container"),j=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),$=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),T=document.getElementById("health-form-save"),z="dashcaddy-health-settings",C={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},H=document.getElementById("health-global-save"),b=document.getElementById("health-global-reset"),d=document.getElementById("health-global-status"),v=document.getElementById("health-setting-retention"),S=document.getElementById("health-setting-interval"),O=document.getElementById("health-setting-stats-interval"),m=document.getElementById("health-setting-max-entries"),f=document.getElementById("health-setting-disk-threshold");function B(){try{const r=safeGet(z),a=r?JSON.parse(r):{};return Object.assign({},C,a)}catch{return Object.assign({},C)}}function w(){const r=B();v&&(v.value=r.retentionDays),S&&(S.value=r.pollingInterval),O&&(O.value=r.statsPollingInterval),m&&(m.value=r.maxEntriesPerService),f&&(f.value=r.diskUsageThreshold)}function L(){const r={retentionDays:Math.max(1,Math.min(3650,parseInt(v?.value)||C.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(S?.value)||C.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(O?.value)||C.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(m?.value)||C.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(f?.value)||C.diskUsageThreshold))};try{safeSet(z,JSON.stringify(r)),w(),d&&(d.textContent="Saved \u2713",d.style.color="var(--ok-fg)",setTimeout(()=>{d&&(d.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(a){d&&(d.textContent="Save failed",d.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+a.message,"error")}}function o(){try{safeSet(z,JSON.stringify(C))}catch{}w(),d&&(d.textContent="Reset to defaults \u2713",d.style.color="var(--ok-fg)",setTimeout(()=>{d&&(d.textContent="")},2500))}w(),H?.addEventListener("click",L),b?.addEventListener("click",o);let p=null;function l(r){return r>=99.9?"var(--ok-fg)":r>=95?"#f39c12":"var(--bad-fg)"}function y(r){const a={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${a[r]||"var(--muted)"}20; color: ${a[r]||"var(--muted)"};">${r}</span>`}async function u(){try{const a=await(await fetch("/api/v1/health-checks/status")).json();if(!a.success||!a.status||Object.keys(a.status).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const i=Object.values(a.status);let c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',c+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',c+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',c+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const I of i){const N=I.status==="up",U=N?"var(--dot-ok)":"var(--dot-bad)",_=I.uptime?.["24h"]??"-",F=I.uptime?.["7d"]??"-",q=I.avgResponseTime!=null?Math.round(I.avgResponseTime)+"ms":"-",J=I.timestamp?timeAgo(I.timestamp):"-";c+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(I.serviceId)}">`,c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(I.name||I.serviceId)}</td>`,c+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${N?"Up":"Down"}</td>`,c+=`<td style="padding: 8px; color: ${typeof _=="number"?l(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,c+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,c+=`<td style="padding: 8px;">${q}</td>`,c+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,c+="</tr>",c+=`<tr id="health-detail-${escapeHtml(I.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
</div>`);const x=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),h=document.getElementById("health-refresh-btn"),P=document.getElementById("health-status-container"),A=document.getElementById("health-incidents-container"),D=document.getElementById("health-config-container"),R=document.getElementById("health-last-update"),y=document.getElementById("health-add-btn"),$=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),T=document.getElementById("health-form-save"),z="dashcaddy-health-settings",C={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},M=document.getElementById("health-global-save"),b=document.getElementById("health-global-reset"),d=document.getElementById("health-global-status"),v=document.getElementById("health-setting-retention"),S=document.getElementById("health-setting-interval"),O=document.getElementById("health-setting-stats-interval"),m=document.getElementById("health-setting-max-entries"),f=document.getElementById("health-setting-disk-threshold");function B(){try{const r=safeGet(z),a=r?JSON.parse(r):{};return Object.assign({},C,a)}catch{return Object.assign({},C)}}function w(){const r=B();v&&(v.value=r.retentionDays),S&&(S.value=r.pollingInterval),O&&(O.value=r.statsPollingInterval),m&&(m.value=r.maxEntriesPerService),f&&(f.value=r.diskUsageThreshold)}function L(){const r={retentionDays:Math.max(1,Math.min(3650,parseInt(v?.value)||C.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(S?.value)||C.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(O?.value)||C.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(m?.value)||C.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(f?.value)||C.diskUsageThreshold))};try{safeSet(z,JSON.stringify(r)),w(),d&&(d.textContent="Saved \u2713",d.style.color="var(--ok-fg)",setTimeout(()=>{d&&(d.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(a){d&&(d.textContent="Save failed",d.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+a.message,"error")}}function o(){try{safeSet(z,JSON.stringify(C))}catch{}w(),d&&(d.textContent="Reset to defaults \u2713",d.style.color="var(--ok-fg)",setTimeout(()=>{d&&(d.textContent="")},2500))}w(),M?.addEventListener("click",L),b?.addEventListener("click",o);let u=null;function l(r){return r>=99.9?"var(--ok-fg)":r>=95?"#f39c12":"var(--bad-fg)"}function g(r){const a={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${a[r]||"var(--muted)"}20; color: ${a[r]||"var(--muted)"};">${r}</span>`}async function p(){try{const a=await(await fetch("/api/v1/health-checks/status")).json();if(!a.success||!a.status||Object.keys(a.status).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const i=Object.values(a.status);let c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',c+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',c+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',c+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const I of i){const N=I.status==="up",U=N?"var(--dot-ok)":"var(--dot-bad)",_=I.uptime?.["24h"]??"-",F=I.uptime?.["7d"]??"-",q=I.avgResponseTime!=null?Math.round(I.avgResponseTime)+"ms":"-",J=I.timestamp?timeAgo(I.timestamp):"-";c+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(I.serviceId)}">`,c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(I.name||I.serviceId)}</td>`,c+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${N?"Up":"Down"}</td>`,c+=`<td style="padding: 8px; color: ${typeof _=="number"?l(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,c+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,c+=`<td style="padding: 8px;">${q}</td>`,c+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,c+="</tr>",c+=`<tr id="health-detail-${escapeHtml(I.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${q.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${l(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
@@ -1574,11 +1574,11 @@ This will replace current configuration, credentials, and data. Containers will
</div>`}else U.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){U.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(r){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(r.message)}</div>`}}async function t(){try{const[r,a]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),i=await r.json(),c=await a.json();let I="";const N=i.success&&i.incidents?i.incidents:[];if(N.length>0){I+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+N.length+")</h4>";for(const _ of N)I+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
<span>${y(_.severity)}</span>
<span>${g(_.severity)}</span>
</div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(_.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(_.createdAt)} \xB7 ${_.occurrences||1} occurrence(s)</div>
</div>`;I+="</div>"}else I+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=c.success&&c.history?c.history:[];if(U.length>0){I+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',I+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',I+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of U){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";I+='<tr style="border-bottom: 1px solid var(--border);">',I+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,I+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,I+=`<td style="padding: 6px;">${y(_.severity)}</td>`,I+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,I+=`<td style="padding: 6px;">${q}</td>`,I+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,I+="</tr>"}I+="</table>"}A.innerHTML=I||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(r){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}async function e(){try{const a=await(await fetch("/api/v1/health-checks/status")).json(),i=a.success&&a.status?Object.values(a.status):[];if(i.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const I of i){const N=I.status==="up";c+='<tr style="border-bottom: 1px solid var(--border);">',c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(I.name||I.serviceId)}</td>`,c+=`<td style="padding: 8px; color: ${N?"var(--ok-fg)":"var(--bad-fg)"};">${N?"Up":"Down"}</td>`,c+=`<td style="padding: 8px;">${I.sla?.target?I.sla.target+"%":"-"}</td>`,c+='<td style="padding: 8px; text-align: right;">',c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(I.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(I.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,c+="</td></tr>"}c+="</table>",D.innerHTML=c}catch(r){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}function s(r,a,i,c,I,N,U){p=r||null,k.textContent=r?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=r||"",document.getElementById("health-form-id").disabled=!!r,document.getElementById("health-form-name").value=a||"",document.getElementById("health-form-url").value=i||"",document.getElementById("health-form-timeout").value=c||1e4,document.getElementById("health-form-codes").value=I||"200",document.getElementById("health-form-sla").value=N||99.9,document.getElementById("health-form-slow").value=U||5e3,$.style.display="",g.style.display="none"}function n(){$.style.display="none",g.style.display="",p=null}g?.addEventListener("click",()=>s("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",n),T?.addEventListener("click",async()=>{const r=p||document.getElementById("health-form-id").value.trim();if(!r)return showNotification("Service ID is required","warning");const a=document.getElementById("health-form-url").value.trim();if(!a)return showNotification("URL is required","warning");const i=document.getElementById("health-form-codes").value.split(",").map(I=>parseInt(I.trim())).filter(Boolean),c={name:document.getElementById("health-form-name").value.trim()||r,url:a,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:i.length?i:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
</div>`;I+="</div>"}else I+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=c.success&&c.history?c.history:[];if(U.length>0){I+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',I+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',I+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of U){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";I+='<tr style="border-bottom: 1px solid var(--border);">',I+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,I+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,I+=`<td style="padding: 6px;">${g(_.severity)}</td>`,I+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,I+=`<td style="padding: 6px;">${q}</td>`,I+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,I+="</tr>"}I+="</table>"}A.innerHTML=I||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(r){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}async function e(){try{const a=await(await fetch("/api/v1/health-checks/status")).json(),i=a.success&&a.status?Object.values(a.status):[];if(i.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const I of i){const N=I.status==="up";c+='<tr style="border-bottom: 1px solid var(--border);">',c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(I.name||I.serviceId)}</td>`,c+=`<td style="padding: 8px; color: ${N?"var(--ok-fg)":"var(--bad-fg)"};">${N?"Up":"Down"}</td>`,c+=`<td style="padding: 8px;">${I.sla?.target?I.sla.target+"%":"-"}</td>`,c+='<td style="padding: 8px; text-align: right;">',c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(I.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(I.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,c+="</td></tr>"}c+="</table>",D.innerHTML=c}catch(r){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}function s(r,a,i,c,I,N,U){u=r||null,k.textContent=r?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=r||"",document.getElementById("health-form-id").disabled=!!r,document.getElementById("health-form-name").value=a||"",document.getElementById("health-form-url").value=i||"",document.getElementById("health-form-timeout").value=c||1e4,document.getElementById("health-form-codes").value=I||"200",document.getElementById("health-form-sla").value=N||99.9,document.getElementById("health-form-slow").value=U||5e3,$.style.display="",y.style.display="none"}function n(){$.style.display="none",y.style.display="",u=null}y?.addEventListener("click",()=>s("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",n),T?.addEventListener("click",async()=>{const r=u||document.getElementById("health-form-id").value.trim();if(!r)return showNotification("Service ID is required","warning");const a=document.getElementById("health-form-url").value.trim();if(!a)return showNotification("URL is required","warning");const i=document.getElementById("health-form-codes").value.split(",").map(I=>parseInt(I.trim())).filter(Boolean),c={name:document.getElementById("health-form-name").value.trim()||r,url:a,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:i.length?i:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle">
@@ -1652,17 +1652,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),R=document.getElementById("updates-cancel"),h=document.getElementById("updates-check-btn"),P=document.getElementById("updates-available-container"),A=document.getElementById("updates-history-container"),D=document.getElementById("updates-auto-container"),j=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){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',j.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let s='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';s+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const a of e){const i=(()=>{const c=window.APPS||[];for(const I of c)if(I.containerId===a.containerId||I.name===a.containerName||I.id===a.containerName)return I.id;return a.containerName})();s+=`<tr data-app-id="${escapeHtml(i)}" style="border-bottom: 1px solid var(--border);">`,s+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(a.containerName)}</td>`,s+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(a.imageName)}</td>`,s+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.currentDigest)}</code></td>`,s+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.latestDigest)}</code></td>`,s+='<td style="padding: 8px; text-align: right;">',s+=`<button class="update-now-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,s+=`<button class="rollback-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,s+="</td></tr>"}s+="</table>",P.innerHTML=s,j.textContent=e.length+" update(s) available";const n=document.getElementById("updates-count-badge"),r=document.getElementById("updates-update-all-btn");n&&(n.textContent=e.length+" pending",n.style.display=""),r&&e.length>0&&(r.style.display=""),window._pendingUpdates=e,P.querySelectorAll(".update-now-btn").forEach(a=>{a.addEventListener("click",async()=>{const i=a.dataset.id,c=a.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){a.textContent="Updating...",a.disabled=!0;try{const N=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(N.success)a.textContent="Done!",a.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(N.error||"Update failed")}catch(I){a.textContent="Failed",a.style.color="var(--bad-fg)",showNotification("Update error: "+I.message,"error"),setTimeout(()=>{a.textContent="Update",a.disabled=!1,a.style.color="",a.style.background=""},3e3)}}})}),P.querySelectorAll(".rollback-btn").forEach(a=>{a.addEventListener("click",async()=>{const i=a.dataset.id,c=a.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){a.textContent="Rolling back...",a.disabled=!0;try{const N=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(N.success)a.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(N.error||"Rollback failed")}catch(I){a.textContent="Failed",showNotification("Rollback error: "+I.message,"error"),setTimeout(()=>{a.textContent="Rollback",a.disabled=!1},3e3)}}})})}catch(u){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.message)}</div>`}}async function $(){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,s=0;for(const n of u)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(n.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:s++}catch{s++}t.textCLine truncated
</div>`);const x=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),j=document.getElementById("updates-cancel"),h=document.getElementById("updates-check-btn"),P=document.getElementById("updates-available-container"),A=document.getElementById("updates-history-container"),D=document.getElementById("updates-auto-container"),R=document.getElementById("updates-last-check");async function y(){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){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',R.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let s='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';s+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const a of e){const i=(()=>{const c=window.APPS||[];for(const I of c)if(I.containerId===a.containerId||I.name===a.containerName||I.id===a.containerName)return I.id;return a.containerName})();s+=`<tr data-app-id="${escapeHtml(i)}" style="border-bottom: 1px solid var(--border);">`,s+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(a.containerName)}</td>`,s+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(a.imageName)}</td>`,s+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.currentDigest)}</code></td>`,s+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.latestDigest)}</code></td>`,s+='<td style="padding: 8px; text-align: right;">',s+=`<button class="update-now-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,s+=`<button class="rollback-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,s+="</td></tr>"}s+="</table>",P.innerHTML=s,R.textContent=e.length+" update(s) available";const n=document.getElementById("updates-count-badge"),r=document.getElementById("updates-update-all-btn");n&&(n.textContent=e.length+" pending",n.style.display=""),r&&e.length>0&&(r.style.display=""),window._pendingUpdates=e,P.querySelectorAll(".update-now-btn").forEach(a=>{a.addEventListener("click",async()=>{const i=a.dataset.id,c=a.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){a.textContent="Updating...",a.disabled=!0;try{const N=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(N.success)a.textContent="Done!",a.style.background="var(--ok-fg)",setTimeout(()=>y(),2e3);else throw new Error(N.error||"Update failed")}catch(I){a.textContent="Failed",a.style.color="var(--bad-fg)",showNotification("Update error: "+I.message,"error"),setTimeout(()=>{a.textContent="Update",a.disabled=!1,a.style.color="",a.style.background=""},3e3)}}})}),P.querySelectorAll(".rollback-btn").forEach(a=>{a.addEventListener("click",async()=>{const i=a.dataset.id,c=a.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){a.textContent="Rolling back...",a.disabled=!0;try{const N=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(N.success)a.textContent="Rolled back!",setTimeout(()=>y(),2e3);else throw new Error(N.error||"Rollback failed")}catch(I){a.textContent="Failed",showNotification("Rollback error: "+I.message,"error"),setTimeout(()=>{a.textContent="Rollback",a.disabled=!1},3e3)}}})})}catch(p){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function $(){const p=window._pendingUpdates||[];if(!p.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${p.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,s=0;for(const n of p)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(n.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:s++}catch{s++}t.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(I)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${U?"":" selected"}>Disabled</option>
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
</select></td>`,a+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(I)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,a+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(I)}"${_?" checked":""} /></td>`,a+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,a+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(I)}" data-name="${escapeHtml(c)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,a+="</tr>"}a+="</table>",D.innerHTML=a,D.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,I=i.closest("tr"),N=I.querySelector(".auto-schedule").value,U=I.querySelector(".auto-rollback").checked,_=I.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!N,schedule:N||"weekly",autoRollback:U,maintenanceWindow:_||void 0})})).json();if(q.success)i.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){i.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(u){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.message)}</div>`}}const z=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),H=document.getElementById("dashcaddy-update-details"),b=document.getElementById("dashcaddy-new-version"),d=document.getElementById("dashcaddy-changelog"),v=document.getElementById("dashcaddy-apply-btn"),S=document.getElementById("dashcaddy-check-btn"),O=document.getElementById("dashcaddy-rollback-btn"),m=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let B=null;function w(u,t){m&&(m.style.display="block",m.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",m.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",m.textContent=u)}async function L(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;z.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{z.textContent="Unable to fetch version"}}async function o(u){u||(S.textContent="Checking...",S.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(B=e,e.success&&e.available&&e.remote){C.style.display="",H.style.display="",b.textContent="v"+e.remote.version,d.textContent=e.remote.changelog||"No changelog available.";const s=document.getElementById("updates-btn");if(s&&!s.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);",s.style.position="relative",s.appendChild(r)}const n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.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;",n.appendChild(r)}}else C.style.display="none",H.style.display="none",await L(),u||w("You are running the latest version.","success");u||(S.textContent="Check for Updates",S.disabled=!1)}catch(t){u||(w("Failed to check: "+t.message,"error"),S.textContent="Check for Updates",S.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;v.textContent="Updating...",v.disabled=!0,w("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return w("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),v.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(u){throw w("Update failed: "+u.message,"error"),v.textContent="Update Now",v.disabled=!1,u}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let s='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';s+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
</select></td>`,a+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(I)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,a+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(I)}"${_?" checked":""} /></td>`,a+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,a+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(I)}" data-name="${escapeHtml(c)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,a+="</tr>"}a+="</table>",D.innerHTML=a,D.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,I=i.closest("tr"),N=I.querySelector(".auto-schedule").value,U=I.querySelector(".auto-rollback").checked,_=I.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!N,schedule:N||"weekly",autoRollback:U,maintenanceWindow:_||void 0})})).json();if(q.success)i.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){i.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(p){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const z=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),M=document.getElementById("dashcaddy-update-details"),b=document.getElementById("dashcaddy-new-version"),d=document.getElementById("dashcaddy-changelog"),v=document.getElementById("dashcaddy-apply-btn"),S=document.getElementById("dashcaddy-check-btn"),O=document.getElementById("dashcaddy-rollback-btn"),m=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let B=null;function w(p,t){m&&(m.style.display="block",m.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",m.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",m.textContent=p)}async function L(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;z.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{z.textContent="Unable to fetch version"}}async function o(p){p||(S.textContent="Checking...",S.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(B=e,e.success&&e.available&&e.remote){C.style.display="",M.style.display="",b.textContent="v"+e.remote.version,d.textContent=e.remote.changelog||"No changelog available.";const s=document.getElementById("updates-btn");if(s&&!s.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);",s.style.position="relative",s.appendChild(r)}const n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.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;",n.appendChild(r)}}else C.style.display="none",M.style.display="none",await L(),p||w("You are running the latest version.","success");p||(S.textContent="Check for Updates",S.disabled=!1)}catch(t){p||(w("Failed to check: "+t.message,"error"),S.textContent="Check for Updates",S.disabled=!1)}}async function u(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;v.textContent="Updating...",v.disabled=!0,w("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return w("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),v.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(p){throw w("Update failed: "+p.message,"error"),v.textContent="Update Now",v.disabled=!1,p}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let s='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';s+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+e.join(`
`)+`
Enter version to rollback to:`);if(!s)return;if(!e.includes(s)){showNotification("Invalid version: "+s,"error");return}if(!confirm("Rollback DashCaddy to v"+s+"? The container will restart."))return;w("Rolling back to v"+s+"...","info");const r=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:s})})).json();if(r.success)w("Rollback to v"+s+" initiated. Container will restart.","success");else throw new Error(r.error||"Rollback failed")}catch(u){w("Rollback failed: "+u.message,"error")}}S?.addEventListener("click",()=>o(!1)),v?.addEventListener("click",()=>p().catch(()=>{})),O?.addEventListener("click",y),h?.addEventListener("click",k),E?.addEventListener("click",()=>{x?.classList.add("show"),g()}),wireModal(x,R),window.openUpdateModal=function(u){x?.classList.add("show"),g().then(()=>{if(!u)return;const t=P.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",M),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",T),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{L(),l(),B||o(!0)}),window.dcApplyUpdate=p,window.dcCheckForUpdate=o,setTimeout(()=>o(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
Enter version to rollback to:`);if(!s)return;if(!e.includes(s)){showNotification("Invalid version: "+s,"error");return}if(!confirm("Rollback DashCaddy to v"+s+"? The container will restart."))return;w("Rolling back to v"+s+"...","info");const r=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:s})})).json();if(r.success)w("Rollback to v"+s+" initiated. Container will restart.","success");else throw new Error(r.error||"Rollback failed")}catch(p){w("Rollback failed: "+p.message,"error")}}S?.addEventListener("click",()=>o(!1)),v?.addEventListener("click",()=>u().catch(()=>{})),O?.addEventListener("click",g),h?.addEventListener("click",k),E?.addEventListener("click",()=>{x?.classList.add("show"),y()}),wireModal(x,j),window.openUpdateModal=function(p){x?.classList.add("show"),y().then(()=>{if(!p)return;const t=P.querySelector(`[data-app-id="${p}"]`);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",T),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{L(),l(),B||o(!0)}),window.dcApplyUpdate=u,window.dcCheckForUpdate=o,setTimeout(()=>o(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1711,7 +1711,7 @@ Enter version to rollback to:`);if(!s)return;if(!e.includes(s)){showNotification
<button id="dr-close">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),R=document.getElementById("dr-close");function h(j){if(!j||j===0)return"0 B";const g=["B","KB","MB","GB","TB"],$=Math.floor(Math.log(Math.abs(j))/Math.log(1024));return(j/Math.pow(1024,$)).toFixed(1)+" "+g[$]}async function P(){const j=document.getElementById("dr-vol-list");try{const $=(await getJSON("/api/v1/docker/volumes")).volumes||[];if($.length===0){j.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of $){const T=M.name==="buildkit"||M.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(M.name)}">${escapeHtml(M.name.length>40?M.name.substring(0,37)+"...":M.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',T||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",j.innerHTML=k,j.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`),P()}catch(T){showNotification("Delete failed: "+T.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(g){j.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const j=document.getElementById("dr-vol-name"),g=j.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),j.value="",showNotification(`Volume "${g}" created`,"success"),P()}catch($){showNotification("Create failed: "+$.message,"error")}});async function A(){const j=document.getElementById("dr-net-list");try{const $=(await getJSON("/api/v1/docker/networks")).networks||[];if($.length===0){j.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of $){const T=["bridge","host","none"].includes(M.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(M.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${M.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',T||(k+=`<button class="dr-net-del" data-id="${escapeHtml(M.id)}" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",j.innerHTML=k,j.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)}`),A()}catch(T){showNotification("Delete failed: "+T.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(g){j.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const j=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),$=j.value.trim();if(!$){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:$,driver:g.value}),j.value="",showNotification(`Network "${$}" created`,"success"),A()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
</div>`);const x=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),j=document.getElementById("dr-close");function h(R){if(!R||R===0)return"0 B";const y=["B","KB","MB","GB","TB"],$=Math.floor(Math.log(Math.abs(R))/Math.log(1024));return(R/Math.pow(1024,$)).toFixed(1)+" "+y[$]}async function P(){const R=document.getElementById("dr-vol-list");try{const $=(await getJSON("/api/v1/docker/volumes")).volumes||[];if($.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of $){const T=H.name==="buildkit"||H.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(H.name)}">${escapeHtml(H.name.length>40?H.name.substring(0,37)+"...":H.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',T||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",R.innerHTML=k,R.querySelectorAll(".dr-vol-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete volume "${H.dataset.name}"? Data will be lost.`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(H.dataset.name)}?force=true`),P()}catch(T){showNotification("Delete failed: "+T.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-vol-name"),y=R.value.trim();if(!y){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:y}),R.value="",showNotification(`Volume "${y}" created`,"success"),P()}catch($){showNotification("Create failed: "+$.message,"error")}});async function A(){const R=document.getElementById("dr-net-list");try{const $=(await getJSON("/api/v1/docker/networks")).networks||[];if($.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of $){const T=["bridge","host","none"].includes(H.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',T||(k+=`<button class="dr-net-del" data-id="${escapeHtml(H.id)}" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",R.innerHTML=k,R.querySelectorAll(".dr-net-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete network "${H.dataset.name}"?`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(H.dataset.id)}`),A()}catch(T){showNotification("Delete failed: "+T.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-net-name"),y=document.getElementById("dr-net-driver"),$=R.value.trim();if(!$){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:$,driver:y.value}),R.value="",showNotification(`Network "${$}" created`,"success"),A()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1752,7 +1752,7 @@ Enter version to rollback to:`);if(!s)return;if(!e.includes(s)){showNotification
<div id="compose-progress-content"></div>
</div>
</div>
</div>`);const x=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),R=document.getElementById("compose-cancel");wireModal(x,R);let h=null;function P(D){document.getElementById("compose-step-paste").style.display=D==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=D==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=D==="progress"?"":"none"}E?.addEventListener("click",()=>{P("paste"),h=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",x?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",D=>{const j=D.target.files[0];if(!j)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(j)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const D=document.getElementById("compose-yaml").value.trim(),j=document.getElementById("compose-stack-name").value.trim()||"stack";if(!D){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),$=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:D,stackName:j});h=k,h.stackName=j,A(k),P("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{g.textContent=$,g.disabled=!1}});function A(D){const j=document.getElementById("compose-preview-content");let g="";D.networks&&D.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${D.networks.map($=>`<code>${escapeHtml($)}</code>`).join(", ")}</div>`),D.volumes&&D.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${D.volumes.map($=>`<code>${escapeHtml($)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${D.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const $ of D.services){const k=$.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml($.name)}`,$.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml($.reason)}</span>`),g+="</div>",!$.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml($.image)}</code></div>`,$.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${$.ports.map(M=>`${M.host}:${M.container}`).join(", ")}</div>`),$.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${$.volumes.length}</div>`),Object.keys($.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys($.environment).length}</div>`),$.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml($.envFileWarning)}</div>`),$.resources?.cpus||$.resources?.memory)){const M=[];$.resources.cpus&&M.push(`CPU: ${$.resources.cpus}`),$.resources.memory&&M.push(`Mem: ${$.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${M.join(", ")}</div>`}g+="</div>"}g+="</div>",j.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>P("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!h)return;const D=document.getElementById("compose-deploy-btn");D.textContent="Deploying...",D.disabled=!0,P("progress");const j=document.getElementById("compose-progress-content");j.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:h.services,networks:h.networks,stackName:h.stackName});let $=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;$+='<div class="scroll-container" style="max-height: 350px;">';for(const k of g.results){const M=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";$+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',$+=`${M} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&($+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&($+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&($+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),$+="</div>"}$+="</div>",$+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
</div>`);const x=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),j=document.getElementById("compose-cancel");wireModal(x,j);let h=null;function P(D){document.getElementById("compose-step-paste").style.display=D==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=D==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=D==="progress"?"":"none"}E?.addEventListener("click",()=>{P("paste"),h=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",x?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",D=>{const R=D.target.files[0];if(!R)return;const y=new FileReader;y.onload=()=>{document.getElementById("compose-yaml").value=y.result},y.readAsText(R)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const D=document.getElementById("compose-yaml").value.trim(),R=document.getElementById("compose-stack-name").value.trim()||"stack";if(!D){showNotification("Paste a docker-compose.yml","warning");return}const y=document.getElementById("compose-parse-btn"),$=y.textContent;y.textContent="Parsing...",y.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:D,stackName:R});h=k,h.stackName=R,A(k),P("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{y.textContent=$,y.disabled=!1}});function A(D){const R=document.getElementById("compose-preview-content");let y="";D.networks&&D.networks.length>0&&(y+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${D.networks.map($=>`<code>${escapeHtml($)}</code>`).join(", ")}</div>`),D.volumes&&D.volumes.length>0&&(y+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${D.volumes.map($=>`<code>${escapeHtml($)}</code>`).join(", ")}</div>`),y+=`<div style="font-weight: 600; margin-bottom: 8px;">${D.services.length} service(s)</div>`,y+='<div class="scroll-container" style="max-height: 350px;">';for(const $ of D.services){const k=$.skip?"var(--bad-fg)":"var(--border)";if(y+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,y+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml($.name)}`,$.skip&&(y+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml($.reason)}</span>`),y+="</div>",!$.skip&&(y+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml($.image)}</code></div>`,$.ports?.length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${$.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),$.volumes?.length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${$.volumes.length}</div>`),Object.keys($.environment||{}).length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys($.environment).length}</div>`),$.envFileWarning&&(y+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml($.envFileWarning)}</div>`),$.resources?.cpus||$.resources?.memory)){const H=[];$.resources.cpus&&H.push(`CPU: ${$.resources.cpus}`),$.resources.memory&&H.push(`Mem: ${$.resources.memory}MB`),y+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}y+="</div>"}y+="</div>",R.innerHTML=y}document.getElementById("compose-back-btn")?.addEventListener("click",()=>P("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!h)return;const D=document.getElementById("compose-deploy-btn");D.textContent="Deploying...",D.disabled=!0,P("progress");const R=document.getElementById("compose-progress-content");R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const y=await postJSON("/api/v1/apps/deploy-compose",{services:h.services,networks:h.networks,stackName:h.stackName});let $=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(y.stackName)}" \u2014 Deployment Complete</div>`;$+='<div class="scroll-container" style="max-height: 350px;">';for(const k of y.results){const H=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";$+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',$+=`${H} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&($+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&($+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&($+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),$+="</div>"}$+="</div>",$+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>P("paste"))}finally{D.textContent="Deploy All",D.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3>
@@ -1761,11 +1761,11 @@ Enter version to rollback to:`);if(!s)return;if(!e.includes(s)){showNotification
<button id="exec-close">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),R=document.getElementById("exec-close");let h=null,P=null,A=null;function D(){if(P){try{P.close()}catch{}P=null}if(h){try{h.dispose()}catch{}h=null}A=null,E.innerHTML=""}function j(g,$){if(D(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${$||g}`,x?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}h=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"&&(A=new FitAddon.FitAddon,h.loadAddon(A)),h.open(E),A&&setTimeout(()=>A.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";P=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(g)}`),P.binaryType="arraybuffer",P.onopen=()=>{if(h.writeln("\x1B[32mConnecting...\x1B[0m"),A){const T=A.proposeDimensions();T&&P.send(JSON.stringify({type:"resize",cols:T.cols,rows:T.rows}))}},P.onmessage=T=>{if(typeof T.data=="string"){try{const z=JSON.parse(T.data);if(z.type==="connected"){h.writeln(`\x1B[32mConnected (${z.shell})\x1B[0m\r
</div>`);const x=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),j=document.getElementById("exec-close");let h=null,P=null,A=null;function D(){if(P){try{P.close()}catch{}P=null}if(h){try{h.dispose()}catch{}h=null}A=null,E.innerHTML=""}function R(y,$){if(D(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${$||y}`,x?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}h=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"&&(A=new FitAddon.FitAddon,h.loadAddon(A)),h.open(E),A&&setTimeout(()=>A.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";P=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(y)}`),P.binaryType="arraybuffer",P.onopen=()=>{if(h.writeln("\x1B[32mConnecting...\x1B[0m"),A){const T=A.proposeDimensions();T&&P.send(JSON.stringify({type:"resize",cols:T.cols,rows:T.rows}))}},P.onmessage=T=>{if(typeof T.data=="string"){try{const z=JSON.parse(T.data);if(z.type==="connected"){h.writeln(`\x1B[32mConnected (${z.shell})\x1B[0m\r
`);return}if(z.type==="error"){h.writeln(`\x1B[31mError: ${z.message}\x1B[0m`);return}if(z.type==="exit"){h.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}h.write(T.data)}else h.write(new Uint8Array(T.data))},P.onclose=()=>{h&&h.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},P.onerror=()=>{h&&h.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},h.onData(T=>{P&&P.readyState===WebSocket.OPEN&&P.send(T)}),h.onResize(({cols:T,rows:z})=>{P&&P.readyState===WebSocket.OPEN&&P.send(JSON.stringify({type:"resize",cols:T,rows:z}))});const M=()=>{A&&A.fit()};window.addEventListener("resize",M),x._resizeHandler=M}R?.addEventListener("click",()=>{D(),x._resizeHandler&&window.removeEventListener("resize",x._resizeHandler),x?.classList.remove("show")}),x?.addEventListener("click",g=>{g.target===x&&(D(),x._resizeHandler&&window.removeEventListener("resize",x._resizeHandler),x?.classList.remove("show"))}),window.openExecModal=j})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
\x1B[31mConnection error.\x1B[0m`)},h.onData(T=>{P&&P.readyState===WebSocket.OPEN&&P.send(T)}),h.onResize(({cols:T,rows:z})=>{P&&P.readyState===WebSocket.OPEN&&P.send(JSON.stringify({type:"resize",cols:T,rows:z}))});const H=()=>{A&&A.fit()};window.addEventListener("resize",H),x._resizeHandler=H}j?.addEventListener("click",()=>{D(),x._resizeHandler&&window.removeEventListener("resize",x._resizeHandler),x?.classList.remove("show")}),x?.addEventListener("click",y=>{y.target===x&&(D(),x._resizeHandler&&window.removeEventListener("resize",x._resizeHandler),x?.classList.remove("show"))}),window.openExecModal=R})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle">
@@ -1811,7 +1811,7 @@ Enter version to rollback to:`);if(!s)return;if(!e.includes(s)){showNotification
<button id="audit-cancel">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),R=document.getElementById("audit-cancel"),h=document.getElementById("audit-refresh-btn"),P=document.getElementById("audit-clear-btn"),A=document.getElementById("audit-filter"),D=document.getElementById("audit-outcome-filter"),j=document.getElementById("audit-since"),g=document.getElementById("audit-until"),$=document.getElementById("audit-log-container"),k=document.getElementById("audit-load-more");let M=0,T=null,z=0;const C=50;function H(S){if(!S)return null;const O=new Date(S);return isNaN(O.getTime())?null:O.toISOString()}async function b(S){try{S?(T&&T.abort(),T=new AbortController):(T&&T.abort(),T=new AbortController,M=0,z++,$.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const O=z,m=new URLSearchParams;m.set("limit",String(C)),m.set("offset",String(M));const f=A.value,B=D.value,w=H(j.value),L=H(g.value);f&&m.set("action",f),B&&m.set("outcome",B),w&&m.set("since",w),L&&m.set("until",L);const o=await fetch("/api/v1/audit-logs?"+m.toString(),{signal:T.signal});if(!o.ok){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${o.status}</div>`,k.style.display="none";return}const p=await o.json();if(!p.success){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.error||"unknown")}</div>`,k.style.display="none";return}if(!S&&O!==z)return;const l=Array.isArray(p.entries)?p.entries:[];if(l.length===0&&!S){const u=p.filters&&(p.filters.action||p.filters.outcome||p.filters.since||p.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";$.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(u)}</div>`,k.style.display="none";return}let y="";S||(y='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',y+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',y+='<th style="padding: 6px; text-align: left;">When</th>',y+='<th style="padding: 6px; text-align: left;">Actor</th>',y+='<th style="padding: 6px; text-align: left;">IP</th>',y+='<th style="padding: 6px; text-align: left;">Action</th>',y+='<th style="padding: 6px; text-align: left;">Resource</th>',y+='<th style="padding: 6px; text-align: left;">Result</th>',y+="</tr>");for(const u of l){const t=u.outcome==="success",e=d(u);y+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',y+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(u.timestamp||"")}">${timeAgo(u.timestamp)}</td>`,y+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,y+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(u.ip||"-")}</td>`,y+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(u.action||"-")}</td>`,y+=`<td style="padding: 6px;">${escapeHtml(u.resource||"-")}</td>`,y+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713":"\u2717"} ${escapeHtml(u.outcome||"")}</span></td>`,y+="</tr>",u.details&&Object.keys(u.details).length>0&&(y+=`<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(u.details,null,2))}</pre></td></tr>`)}if(!S)y+="</table>",$.innerHTML=y;else{const u=$.querySelector("table");u&&u.insertAdjacentHTML("beforeend",y)}M+=l.length,k.style.display=p.hasMore?"":"none",$.querySelectorAll(".audit-row").forEach(u=>{u.dataset.wired||(u.dataset.wired="true",u.addEventListener("click",()=>{const t=u.nextElementSibling;t&&t.classList.contains("audit-detail")&&(t.style.display=t.style.display==="none"?"":"none")}))})}catch(O){if(O&&O.name==="AbortError")return;$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(O.message)}</div>`}}function d(S){const O=S.details||{},m=O.userEmail,f=O.userId,B=O.userRole,w=O.viaProvider;if(m){const L=B?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(B)}${w?"/"+escapeHtml(w):""}]</span>`:"";return`${escapeHtml(m)}${L}`}return f?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(f)}</span>`:S.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}E?.addEventListener("click",()=>{x?.classList.add("show"),b(!1)}),wireModal(x,R),h?.addEventListener("click",()=>b(!1)),A?.addEventListener("change",()=>b(!1)),D?.addEventListener("change",()=>b(!1));let v;[j,g].forEach(S=>{S?.addEventListener("change",()=>{clearTimeout(v),v=setTimeout(()=>b(!1),250)})}),k?.addEventListener("click",()=>b(!0)),P?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const O=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
</div>`);const x=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),j=document.getElementById("audit-cancel"),h=document.getElementById("audit-refresh-btn"),P=document.getElementById("audit-clear-btn"),A=document.getElementById("audit-filter"),D=document.getElementById("audit-outcome-filter"),R=document.getElementById("audit-since"),y=document.getElementById("audit-until"),$=document.getElementById("audit-log-container"),k=document.getElementById("audit-load-more");let H=0,T=null,z=0;const C=50;function M(S){if(!S)return null;const O=new Date(S);return isNaN(O.getTime())?null:O.toISOString()}async function b(S){try{S?(T&&T.abort(),T=new AbortController):(T&&T.abort(),T=new AbortController,H=0,z++,$.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const O=z,m=new URLSearchParams;m.set("limit",String(C)),m.set("offset",String(H));const f=A.value,B=D.value,w=M(R.value),L=M(y.value);f&&m.set("action",f),B&&m.set("outcome",B),w&&m.set("since",w),L&&m.set("until",L);const o=await fetch("/api/v1/audit-logs?"+m.toString(),{signal:T.signal});if(!o.ok){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${o.status}</div>`,k.style.display="none";return}const u=await o.json();if(!u.success){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.error||"unknown")}</div>`,k.style.display="none";return}if(!S&&O!==z)return;const l=Array.isArray(u.entries)?u.entries:[];if(l.length===0&&!S){const p=u.filters&&(u.filters.action||u.filters.outcome||u.filters.since||u.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";$.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(p)}</div>`,k.style.display="none";return}let g="";S||(g='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',g+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',g+='<th style="padding: 6px; text-align: left;">When</th>',g+='<th style="padding: 6px; text-align: left;">Actor</th>',g+='<th style="padding: 6px; text-align: left;">IP</th>',g+='<th style="padding: 6px; text-align: left;">Action</th>',g+='<th style="padding: 6px; text-align: left;">Resource</th>',g+='<th style="padding: 6px; text-align: left;">Result</th>',g+="</tr>");for(const p of l){const t=p.outcome==="success",e=d(p);g+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',g+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(p.timestamp||"")}">${timeAgo(p.timestamp)}</td>`,g+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,g+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(p.ip||"-")}</td>`,g+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(p.action||"-")}</td>`,g+=`<td style="padding: 6px;">${escapeHtml(p.resource||"-")}</td>`,g+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713":"\u2717"} ${escapeHtml(p.outcome||"")}</span></td>`,g+="</tr>",p.details&&Object.keys(p.details).length>0&&(g+=`<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(p.details,null,2))}</pre></td></tr>`)}if(!S)g+="</table>",$.innerHTML=g;else{const p=$.querySelector("table");p&&p.insertAdjacentHTML("beforeend",g)}H+=l.length,k.style.display=u.hasMore?"":"none",$.querySelectorAll(".audit-row").forEach(p=>{p.dataset.wired||(p.dataset.wired="true",p.addEventListener("click",()=>{const t=p.nextElementSibling;t&&t.classList.contains("audit-detail")&&(t.style.display=t.style.display==="none"?"":"none")}))})}catch(O){if(O&&O.name==="AbortError")return;$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(O.message)}</div>`}}function d(S){const O=S.details||{},m=O.userEmail,f=O.userId,B=O.userRole,w=O.viaProvider;if(m){const L=B?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(B)}${w?"/"+escapeHtml(w):""}]</span>`:"";return`${escapeHtml(m)}${L}`}return f?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(f)}</span>`:S.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}E?.addEventListener("click",()=>{x?.classList.add("show"),b(!1)}),wireModal(x,j),h?.addEventListener("click",()=>b(!1)),A?.addEventListener("change",()=>b(!1)),D?.addEventListener("change",()=>b(!1));let v;[R,y].forEach(S=>{S?.addEventListener("change",()=>{clearTimeout(v),v=setTimeout(()=>b(!1),250)})}),k?.addEventListener("click",()=>b(!0)),P?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const O=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
<h3>\u{1F6E1}\uFE0F Security Center</h3>
<p class="modal-subtitle">
@@ -1890,39 +1890,39 @@ Enter version to rollback to:`);if(!s)return;if(!e.includes(s)){showNotification
<button id="sec-cancel">Close</button>
</div>
</div>
</div>`);const x=document.getElementById("security-modal"),E=document.getElementById("security-center-btn"),R=document.getElementById("sec-cancel"),h=x.querySelectorAll(".sec-tab"),P=x.querySelectorAll(".sec-panel");let A=[],D=[],j=null;h.forEach(o=>{o.addEventListener("click",()=>{h.forEach(p=>p.classList.toggle("active",p===o)),P.forEach(p=>p.style.display=p.dataset.panel===o.dataset.tab?"":"none"),o.dataset.tab==="overview"&&M(),o.dataset.tab==="events"&&v(),o.dataset.tab==="hosts"&&f()})}),E&&E.addEventListener("click",()=>{x.classList.add("show"),M(),$()}),R.addEventListener("click",g),x.addEventListener("click",o=>{o.target===x&&g()});function g(){x.classList.remove("show"),k()}function $(){if(k(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{j=new EventSource("/api/v1/security/events/stream"),j.addEventListener("init",o=>{try{A=JSON.parse(o.data).events||[],S()}catch{}}),j.addEventListener("security",o=>{try{const p=JSON.parse(o.data);A.unshift(p),A.length>500&&(A.length=500);const l=x.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?S():l==="overview"&&M()}catch{}}),j.onerror=()=>{}}catch(o){console.warn("[security] SSE failed:",o.message)}}function k(){if(j){try{j.close()}catch{}j=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{x.classList.contains("show")&&$()});async function M(){try{const o=new Date(Date.now()-864e5).toISOString(),[p,l,y]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(o)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(o)}`)]),u=(await p.json()).data||{},t=(await l.json()).data?.hosts||[],e=(await y.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`,T("sec-top-actors",u.top_actors||[]),T("sec-top-targets",u.top_targets||[])}catch(o){console.warn("[security] refreshOverview failed:",o.message)}}function T(o,p){const l=document.getElementById(o);if(!p.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+p.map(y=>`<tr><td style="padding:3px 0;word-break:break-all;">${w(String(y.key))}</td><td style="text-align:right;color:var(--muted);">${y.count}</td></tr>`).join("")+"</table>"}const z=document.getElementById("sec-filter-source"),C=document.getElementById("sec-filter-severity"),H=document.getElementById("sec-filter-host"),b=document.getElementById("sec-filter-actor"),d=document.getElementById("sec-refresh-btn");[z,C,H].forEach(o=>o.addEventListener("change",v)),b.addEventListener("input",L(v,250)),d.addEventListener("click",v);async function v(){try{const o=new URLSearchParams;o.set("limit","200"),z.value&&o.set("source_type",z.value),C.value&&o.set("severity",C.value),H.value&&o.set("source_host",H.value),b.value&&o.set("actor_prefix",b.value),A=(await(await fetch(`/api/v1/security/events?${o}`)).json()).data.events||[],S(),(!H.options.length||H.options.length===1)&&await m()}catch(o){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+w(o.message)+"</div>"}}function S(){const o=document.getElementById("sec-events-container");if(!A.length){o.innerHTML='<div class="panel-empty">No events</div>';return}o.innerHTML=A.slice(0,200).map(O).join("")}function O(o){const p=o.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[p]||"#7f8c8d",y=o.ts?new Date(o.ts).toLocaleTimeString():"",u=o.source_type||"",t=o.actor||"\u2014",e=o.target||"",s=o.action||"",n=o.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${l};font-weight:600;">${w(p)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${w(u)}</span>
</div>`);const x=document.getElementById("security-modal"),E=document.getElementById("security-center-btn"),j=document.getElementById("sec-cancel"),h=x.querySelectorAll(".sec-tab"),P=x.querySelectorAll(".sec-panel");let A=[],D=[],R=null;h.forEach(o=>{o.addEventListener("click",()=>{h.forEach(u=>u.classList.toggle("active",u===o)),P.forEach(u=>u.style.display=u.dataset.panel===o.dataset.tab?"":"none"),o.dataset.tab==="overview"&&H(),o.dataset.tab==="events"&&v(),o.dataset.tab==="hosts"&&f()})}),E&&E.addEventListener("click",()=>{x.classList.add("show"),H(),$()}),j.addEventListener("click",y),x.addEventListener("click",o=>{o.target===x&&y()});function y(){x.classList.remove("show"),k()}function $(){if(k(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{R=new EventSource("/api/v1/security/events/stream"),R.addEventListener("init",o=>{try{A=JSON.parse(o.data).events||[],S()}catch{}}),R.addEventListener("security",o=>{try{const u=JSON.parse(o.data);A.unshift(u),A.length>500&&(A.length=500);const l=x.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?S():l==="overview"&&H()}catch{}}),R.onerror=()=>{}}catch(o){console.warn("[security] SSE failed:",o.message)}}function k(){if(R){try{R.close()}catch{}R=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{x.classList.contains("show")&&$()});async function H(){try{const o=new Date(Date.now()-864e5).toISOString(),[u,l,g]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(o)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(o)}`)]),p=(await u.json()).data||{},t=(await l.json()).data?.hosts||[],e=(await g.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${p.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${p.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${p.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,T("sec-top-actors",p.top_actors||[]),T("sec-top-targets",p.top_targets||[])}catch(o){console.warn("[security] refreshOverview failed:",o.message)}}function T(o,u){const l=document.getElementById(o);if(!u.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+u.map(g=>`<tr><td style="padding:3px 0;word-break:break-all;">${w(String(g.key))}</td><td style="text-align:right;color:var(--muted);">${g.count}</td></tr>`).join("")+"</table>"}const z=document.getElementById("sec-filter-source"),C=document.getElementById("sec-filter-severity"),M=document.getElementById("sec-filter-host"),b=document.getElementById("sec-filter-actor"),d=document.getElementById("sec-refresh-btn");[z,C,M].forEach(o=>o.addEventListener("change",v)),b.addEventListener("input",L(v,250)),d.addEventListener("click",v);async function v(){try{const o=new URLSearchParams;o.set("limit","200"),z.value&&o.set("source_type",z.value),C.value&&o.set("severity",C.value),M.value&&o.set("source_host",M.value),b.value&&o.set("actor_prefix",b.value),A=(await(await fetch(`/api/v1/security/events?${o}`)).json()).data.events||[],S(),(!M.options.length||M.options.length===1)&&await m()}catch(o){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+w(o.message)+"</div>"}}function S(){const o=document.getElementById("sec-events-container");if(!A.length){o.innerHTML='<div class="panel-empty">No events</div>';return}o.innerHTML=A.slice(0,200).map(O).join("")}function O(o){const u=o.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[u]||"#7f8c8d",g=o.ts?new Date(o.ts).toLocaleTimeString():"",p=o.source_type||"",t=o.actor||"\u2014",e=o.target||"",s=o.action||"",n=o.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${l};font-weight:600;">${w(u)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${w(p)}</span>
<span title="${w(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${w(t)}</span>
<span title="${w(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${w(s)} ${w(e)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${w(n)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${w(y)}</span>
</div>`}async function m(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=H.value;H.innerHTML='<option value="">All hosts</option>'+p.map(y=>`<option value="${w(y.id)}">${w(y.label||y.id)}</option>`).join(""),l&&(H.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",B),document.getElementById("sec-hosts-refresh").addEventListener("click",f);async function f(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];D=p;const l=document.getElementById("sec-hosts-container");if(!p.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=p.map(y=>{const u=y.enabled?y.last_seen_at?Date.now()-Date.parse(y.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${w(g)}</span>
</div>`}async function m(){try{const u=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=M.value;M.innerHTML='<option value="">All hosts</option>'+u.map(g=>`<option value="${w(g.id)}">${w(g.label||g.id)}</option>`).join(""),l&&(M.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",B),document.getElementById("sec-hosts-refresh").addEventListener("click",f);async function f(){try{const u=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];D=u;const l=document.getElementById("sec-hosts-container");if(!u.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=u.map(g=>{const p=g.enabled?g.last_seen_at?Date.now()-Date.parse(g.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${w(y.label||y.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${w(y.type)}</span>
<strong>${w(g.label||g.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${w(g.type)}</span>
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
id: ${w(y.id)} \xB7
registered ${new Date(y.registered_at).toLocaleDateString()} \xB7
last seen ${y.last_seen_at?new Date(y.last_seen_at).toLocaleString():"never"}
id: ${w(g.id)} \xB7
registered ${new Date(g.registered_at).toLocaleDateString()} \xB7
last seen ${g.last_seen_at?new Date(g.last_seen_at).toLocaleString():"never"}
</div>
</div>
<div>
<span style="font-size:0.85rem;margin-right:12px;">${u}</span>
${y.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${w(y.id)}" style="color:var(--bad-fg);">Remove</button>`}
<span style="font-size:0.85rem;margin-right:12px;">${p}</span>
${g.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${w(g.id)}" style="color:var(--bad-fg);">Remove</button>`}
</div>
</div>`}).join(""),l.querySelectorAll(".sec-host-del").forEach(y=>{y.addEventListener("click",async()=>{confirm(`Remove host ${y.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(y.dataset.id)}`,{method:"DELETE"}),f())})})}catch(o){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+w(o.message)+"</div>"}}async function B(){const o=prompt("Host id (lowercase, no spaces):");if(!o)return;const p=prompt("Display label:",o)||o,l=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const y=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:o,label:p,type:l})}),u=await y.json();if(!y.ok){alert("Failed: "+(u?.error?.message||y.statusText));return}alert(`\u2705 Host registered!
</div>`}).join(""),l.querySelectorAll(".sec-host-del").forEach(g=>{g.addEventListener("click",async()=>{confirm(`Remove host ${g.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(g.dataset.id)}`,{method:"DELETE"}),f())})})}catch(o){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+w(o.message)+"</div>"}}async function B(){const o=prompt("Host id (lowercase, no spaces):");if(!o)return;const u=prompt("Display label:",o)||o,l=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const g=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:o,label:u,type:l})}),p=await g.json();if(!g.ok){alert("Failed: "+(p?.error?.message||g.statusText));return}alert(`\u2705 Host registered!
id: ${u.data.host.id}
label: ${u.data.host.label}
type: ${u.data.host.type}
id: ${p.data.host.id}
label: ${p.data.host.label}
type: ${p.data.host.type}
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
${u.data.api_key}
${p.data.api_key}
Send this key as: Authorization: Bearer <api_key>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),f()}catch(y){alert("Failed: "+y.message)}}function w(o){return String(o).replace(/[&<>"']/g,p=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[p])}function L(o,p){let l;return function(){clearTimeout(l),l=setTimeout(()=>o.apply(this,arguments),p)}}})(),window.__dc133_buildRepoOptions=function(x,E){var R=E||function(h){return String(h).replace(/[&<>"']/g,function(P){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[P]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(x||[]).map(function(h){return'<option value="'+R(h.url)+'" data-id="'+R(h.id)+'">'+R(h.full_name)+(h.description?" \u2014 "+R(h.description):"")+"</option>"}).join("")},window.__dc133_requestToken=function(x,E){if(x)return"";var R=String(E||"").trim();return R||void 0},(function(){injectModal("deploys-modal",`<div id="deploys-modal" class="weather-modal">
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),f()}catch(g){alert("Failed: "+g.message)}}function w(o){return String(o).replace(/[&<>"']/g,u=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[u])}function L(o,u){let l;return function(){clearTimeout(l),l=setTimeout(()=>o.apply(this,arguments),u)}}})(),window.__dc133_buildRepoOptions=function(x,E){var j=E||function(h){return String(h).replace(/[&<>"']/g,function(P){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[P]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(x||[]).map(function(h){return'<option value="'+j(h.url)+'" data-id="'+j(h.id)+'">'+j(h.full_name)+(h.description?" \u2014 "+j(h.description):"")+"</option>"}).join("")},window.__dc133_requestToken=function(x,E){if(x)return"";var j=String(E||"").trim();return j||void 0},(function(){injectModal("deploys-modal",`<div id="deploys-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 860px; max-width: 1200px;">
<h3>\u{1F69A} Deploys</h3>
<p class="modal-subtitle">
@@ -2006,16 +2006,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),f()}catch(y)
<button class="btn" id="dep-close">Close</button>
</div>
</div>
</div>`);const x=b=>document.getElementById(b);let E=null,R=!1;function h(b){return window.escapeHtml?window.escapeHtml(String(b)):String(b).replace(/[&<>"']/g,d=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[d])}async function P(b,d){const v=await fetch("/dashcaddy-api/api/v1/deploys"+b,Object.assign({credentials:"include"},d||{}));let S=null;try{S=await v.json()}catch{S={success:!1,error:"non-JSON response"}}return{status:v.status,body:S}}function A(b){const d=x("dep-output");d.style.display="block",d.textContent=b||"(no output)",d.scrollTop=d.scrollHeight}async function D(){const b=x("dep-services"),{body:d}=await P("/services");if(!d.success){b.innerHTML="<em>"+h(d.error||"unavailable")+"</em>";return}const v=d.services||[];if(!v.length){b.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}b.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th></tr></thead><tbody>'+v.map(S=>"<tr><td><strong>"+h(S.name)+"</strong></td><td>"+h(S.last_action)+"</td><td>"+h(S.last_time)+"</td><td>"+h(S.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+h(S.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+h(S.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function j(){const b=x("dep-repo-select"),{body:d}=await P("/repos");if(!d.success){b.innerHTML="<option>"+h(d.error||"unavailable")+"</option>";return}const v=d.repos||[];b.innerHTML=v.length?v.map(S=>'<option value="'+h(S.dir)+'">'+h(S.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function g(b){const d=x("dep-journal"),v=b?"?service="+encodeURIComponent(b):"",{body:S}=await P("/journal"+v),O=S.rows||[];if(!O.length){d.innerHTML="<em>No journal rows.</em>";return}d.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th></tr></thead><tbody>'+O.map(m=>"<tr><td>"+h(m.time)+"</td><td>"+h(m.service)+"</td><td>"+h(m.action)+"</td><td>"+h(m.epoch)+"</td><td>"+h(m.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function $(){const b=document.getElementById("dep-gh-gitea"),d=document.getElementById("dep-gh-gitea-host"),v=document.getElementById("dep-gh-gitea-token"),S=document.getElementById("dep-gh-anonymous"),O={};if(d&&d.value.trim()&&(O.gitea_url="https://"+d.value.trim().replace(/^https?:\/*/,"")),S&&S.checked)O.token="";else{const m=window.__dc133_requestToken(!1,v&&v.value);m!==void 0&&(O.token=m)}try{const{status:m,body:f}=await P("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(O)});if(!f.success||!(f.repos||[]).length){b.innerHTML='<option value="">'+h(f.error||"no repos found")+"</option>";return}b.innerHTML=window.__dc133_buildRepoOptions(f.repos,h)}catch{b.innerHTML='<option value="">unavailable</option>'}}async function k(){const b=document.getElementById("dep-gh-btn"),d=document.getElementById("dep-gh-url").value.trim(),v=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),S=document.getElementById("dep-gh-args").value.trim();if(!d||!v){A("Repo URL and name are required.");return}const O=S?S.split(/\s+/):[];M(!0,b,"Install"),A("Installing "+d+`
</div>`);const x=b=>document.getElementById(b);let E=null,j=!1;function h(b){return window.escapeHtml?window.escapeHtml(String(b)):String(b).replace(/[&<>"']/g,d=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[d])}async function P(b,d){const v=await fetch("/dashcaddy-api/api/v1/deploys"+b,Object.assign({credentials:"include"},d||{}));let S=null;try{S=await v.json()}catch{S={success:!1,error:"non-JSON response"}}return{status:v.status,body:S}}function A(b){const d=x("dep-output");d.style.display="block",d.textContent=b||"(no output)",d.scrollTop=d.scrollHeight}async function D(){const b=x("dep-services"),{body:d}=await P("/services");if(!d.success){b.innerHTML="<em>"+h(d.error||"unavailable")+"</em>";return}const v=d.services||[];if(!v.length){b.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}b.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th></tr></thead><tbody>'+v.map(S=>"<tr><td><strong>"+h(S.name)+"</strong></td><td>"+h(S.last_action)+"</td><td>"+h(S.last_time)+"</td><td>"+h(S.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+h(S.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+h(S.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function R(){const b=x("dep-repo-select"),{body:d}=await P("/repos");if(!d.success){b.innerHTML="<option>"+h(d.error||"unavailable")+"</option>";return}const v=d.repos||[];b.innerHTML=v.length?v.map(S=>'<option value="'+h(S.dir)+'">'+h(S.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function y(b){const d=x("dep-journal"),v=b?"?service="+encodeURIComponent(b):"",{body:S}=await P("/journal"+v),O=S.rows||[];if(!O.length){d.innerHTML="<em>No journal rows.</em>";return}d.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th></tr></thead><tbody>'+O.map(m=>"<tr><td>"+h(m.time)+"</td><td>"+h(m.service)+"</td><td>"+h(m.action)+"</td><td>"+h(m.epoch)+"</td><td>"+h(m.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function $(){const b=document.getElementById("dep-gh-gitea"),d=document.getElementById("dep-gh-gitea-host"),v=document.getElementById("dep-gh-gitea-token"),S=document.getElementById("dep-gh-anonymous"),O={};if(d&&d.value.trim()&&(O.gitea_url="https://"+d.value.trim().replace(/^https?:\/*/,"")),S&&S.checked)O.token="";else{const m=window.__dc133_requestToken(!1,v&&v.value);m!==void 0&&(O.token=m)}try{const{status:m,body:f}=await P("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(O)});if(!f.success||!(f.repos||[]).length){b.innerHTML='<option value="">'+h(f.error||"no repos found")+"</option>";return}b.innerHTML=window.__dc133_buildRepoOptions(f.repos,h)}catch{b.innerHTML='<option value="">unavailable</option>'}}async function k(){const b=document.getElementById("dep-gh-btn"),d=document.getElementById("dep-gh-url").value.trim(),v=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),S=document.getElementById("dep-gh-args").value.trim();if(!d||!v){A("Repo URL and name are required.");return}const O=S?S.split(/\s+/):[];H(!0,b,"Install"),A("Installing "+d+`
Cloning, building, gating and DNS-ing... (~30-60s)`);try{const m=!!(document.getElementById("dep-gh-anonymous")||{}).checked,f=(document.getElementById("dep-gh-gitea-token")||{value:""}).value,B=window.__dc133_requestToken(m,f),{status:w,body:L}=await P("/install",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo_url:d,service:v,args:O,token:B})});if(!L.success){A((L.output?L.output+`
`:"")+"FAIL "+(L.error||"HTTP "+w)),M(!1,b,"Install");return}const o=L.service||{};if(!(window.APPS||[]).some(l=>l.id===o.id)){const l={id:o.id,name:o.name||o.id,url:o.url,logo:o.logo,tailscaleOnly:!0,isCustom:!0};try{await fetch("/dashcaddy-api/api/v1/services",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}),window.APPS.push(l),typeof window.renderApps=="function"&&window.renderApps(),typeof window.renderGrid=="function"&&window.renderGrid()}catch{}}A("INSTALLED "+(o.name||o.id)+" in "+(o.deploy_seconds||"?")+`s
`:"")+"FAIL "+(L.error||"HTTP "+w)),H(!1,b,"Install");return}const o=L.service||{};if(!(window.APPS||[]).some(l=>l.id===o.id)){const l={id:o.id,name:o.name||o.id,url:o.url,logo:o.logo,tailscaleOnly:!0,isCustom:!0};try{await fetch("/dashcaddy-api/api/v1/services",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}),window.APPS.push(l),typeof window.renderApps=="function"&&window.renderApps(),typeof window.renderGrid=="function"&&window.renderGrid()}catch{}}A("INSTALLED "+(o.name||o.id)+" in "+(o.deploy_seconds||"?")+`s
Card: `+(o.url||"")+`
`+(L.output||"").slice(-800)),D()}catch(m){A("install error: "+m.message)}M(!1,b,"Install")}function M(b,d,v){d&&(d.disabled=b,v&&(d.textContent=b?"Working\u2026":v))}async function T(){const b=x("dep-deploy-btn"),d=x("dep-repo-select").value;if(d){M(!0,b,"Deploy"),A("Deploying "+d+`
`+(L.output||"").slice(-800)),D()}catch(m){A("install error: "+m.message)}H(!1,b,"Install")}function H(b,d,v){d&&(d.disabled=b,v&&(d.textContent=b?"Working\u2026":v))}async function T(){const b=x("dep-deploy-btn"),d=x("dep-repo-select").value;if(d){H(!0,b,"Deploy"),A("Deploying "+d+`
This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir:d})});A((v.output||v.error||"")+(v.success?`
\u2705 SUCCESS`:`
\u274C FAILED`)),D()}catch(v){A("deploy error: "+v.message)}M(!1,b,"Deploy")}}async function z(b,d){if(confirm("Roll back "+b+" to the previous release?")){M(!0,d,"Rollback"),A("Rolling back "+b+"\u2026");try{const{body:v}=await P("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:b})});A((v.output||v.error||"")+(v.success?`
\u274C FAILED`)),D()}catch(v){A("deploy error: "+v.message)}H(!1,b,"Deploy")}}async function z(b,d){if(confirm("Roll back "+b+" to the previous release?")){H(!0,d,"Rollback"),A("Rolling back "+b+"\u2026");try{const{body:v}=await P("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:b})});A((v.output||v.error||"")+(v.success?`
\u2705 ROLLED BACK`:`
\u274C FAILED`)),D()}catch(v){A("rollback error: "+v.message)}M(!1,d,"Rollback")}}async function C(b,d){M(!0,d,"Status");try{const{body:v}=await P("/status?service="+encodeURIComponent(b));A(v.output||v.error||"(no output)")}catch(v){A("status error: "+v.message)}M(!1,d,"Status")}function H(){E=document.getElementById("deploys-btn"),E&&(E.addEventListener("click",async()=>{x("deploys-modal").classList.add("open"),R||(R=!0,D(),j(),g(""),$())}),x("dep-close").addEventListener("click",()=>{x("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(b=>{b.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(d=>d.classList.remove("active")),b.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(d=>{d.style.display=d.dataset.panel===b.dataset.tab?"block":"none"})})}),x("dep-deploy-btn").addEventListener("click",T),x("dep-gh-btn").addEventListener("click",k),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(b=>{const d=document.getElementById(b);d&&d.addEventListener("change",()=>$())}),document.getElementById("dep-gh-gitea").addEventListener("change",b=>{const d=b.target.value;if(d){document.getElementById("dep-gh-url").value=d;const v=b.target.selectedOptions[0],S=v&&v.dataset?v.dataset.id:"",O=document.getElementById("dep-gh-service");S&&!O.value&&(O.value=S)}}),x("dep-journal-btn").addEventListener("click",()=>g(x("dep-journal-name").value.trim())),x("dep-status-btn").addEventListener("click",()=>{const b=x("dep-status-name").value.trim();b&&C(b)}),x("dep-services").addEventListener("click",b=>{const d=b.target.closest(".dep-rollback");if(d)return z(d.dataset.service,d);const v=b.target.closest(".dep-status");if(v)return C(v.dataset.service,v)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",H):H()})(),(function(){const x=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
\u274C FAILED`)),D()}catch(v){A("rollback error: "+v.message)}H(!1,d,"Rollback")}}async function C(b,d){H(!0,d,"Status");try{const{body:v}=await P("/status?service="+encodeURIComponent(b));A(v.output||v.error||"(no output)")}catch(v){A("status error: "+v.message)}H(!1,d,"Status")}function M(){E=document.getElementById("deploys-btn"),E&&(E.addEventListener("click",async()=>{x("deploys-modal").classList.add("open"),j||(j=!0,D(),R(),y(""),$())}),x("dep-close").addEventListener("click",()=>{x("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(b=>{b.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(d=>d.classList.remove("active")),b.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(d=>{d.style.display=d.dataset.panel===b.dataset.tab?"block":"none"})})}),x("dep-deploy-btn").addEventListener("click",T),x("dep-gh-btn").addEventListener("click",k),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(b=>{const d=document.getElementById(b);d&&d.addEventListener("change",()=>$())}),document.getElementById("dep-gh-gitea").addEventListener("change",b=>{const d=b.target.value;if(d){document.getElementById("dep-gh-url").value=d;const v=b.target.selectedOptions[0],S=v&&v.dataset?v.dataset.id:"",O=document.getElementById("dep-gh-service");S&&!O.value&&(O.value=S)}}),x("dep-journal-btn").addEventListener("click",()=>y(x("dep-journal-name").value.trim())),x("dep-status-btn").addEventListener("click",()=>{const b=x("dep-status-name").value.trim();b&&C(b)}),x("dep-services").addEventListener("click",b=>{const d=b.target.closest(".dep-rollback");if(d)return z(d.dataset.service,d);const v=b.target.closest(".dep-status");if(v)return C(v.dataset.service,v)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",M):M()})(),(function(){const x=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
<label for="weather-location-input">Location:</label>
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -2026,7 +2026,7 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label>
</div>
</div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",R="weather-zip",h="weather-geo",P="weather-unit";!safeGet(E)&&safeGet(R)&&safeSet(E,safeGet(R));function A(){return safeGet(P)||"imperial"}function D(){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 j={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"},$=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(d){return $[Math.round(d/22.5)%16]}async function M(d){const v=safeGet(h);if(v)try{const B=JSON.parse(v);if(B.query===d)return B}catch{}const S=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(d)}&count=1&language=en&format=json`);if(!S.ok)throw new Error("Geocoding failed");const O=await S.json();if(!O.results||!O.results.length)throw new Error("Location not found");const m=O.results[0],f={query:d,lat:m.latitude,lon:m.longitude,city:m.name,state:m.admin1||"",country:m.country||"",countryCode:m.country_code||""};return safeSet(h,JSON.stringify(f)),f}function T(d){return d.countryCode==="US"&&d.state?`${d.city}, ${d.state}`:d.country?`${d.city}, ${d.country}`:d.city}async function z(d){try{const v=await M(d),S=A(),O=S==="metric"?"celsius":"fahrenheit",m=S==="metric"?"kmh":"mph",f=`https://api.open-meteo.com/v1/forecast?latitude=${v.lat}&longitude=${v.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${O}&wind_speed_unit=${m}`,B=await fetch(f);if(!B.ok)throw new Error("Weather fetch failed");const L=(await B.json()).current,o=L.weather_code;return{temp:Math.round(L.temperature_2m),condition:j[o]||"Unknown",icon:g[o]||"\u{1F324}\uFE0F",locationStr:T(v),windSpeed:Math.round(L.wind_speed_10m),windDir:k(L.wind_direction_10m),unit:S}}catch(v){return console.warn("Weather fetch failed:",v),null}}async function C(){const d=D();if(!d.icon||!d.temp||!d.condition||!d.location||!d.wind){console.warn("Weather widget elements not found");return}const v=safeGet(E);if(!v){d.location.textContent="Set Location",d.temp.textContent="--\xB0",d.condition.textContent="Click \u2699\uFE0F to configure",d.wind.textContent="--",d.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const S=await z(v);if(S){const O=S.unit==="metric"?"\xB0C":"\xB0F",m=S.unit==="metric"?"km/h":"mph";d.location.textContent=S.locationStr,d.temp.textContent=`${S.temp}${O}`,d.condition.textContent=S.condition,d.wind.textContent=`Wind: ${S.windSpeed} ${m} ${S.windDir}`,d.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(S.icon)}</span>`}}catch(S){x.logError("[Weather] Update Error",S,{function:"updateWeather"}),d.location.textContent="Weather Error",d.temp.textContent="Error",d.condition.textContent="Failed to load",d.wind.textContent="--"}}const H=document.getElementById("weather-modal"),b=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{b.value=safeGet(E)||"";const d=A(),v=H.querySelector(`input[name="weather-unit-radio"][value="${d}"]`);v&&(v.checked=!0),H.classList.add("show"),b.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{H.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const d=b.value.trim();if(d){safeGet(E)!==d&&safeSet(h,""),safeSet(E,d);const S=H.querySelector('input[name="weather-unit-radio"]:checked'),O=S?S.value:"imperial",m=A();safeSet(P,O),m!==O&&safeSet(h,""),H.classList.remove("show"),C()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(H),document.addEventListener("keydown",d=>{d.keyLine truncated
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",j="weather-zip",h="weather-geo",P="weather-unit";!safeGet(E)&&safeGet(j)&&safeSet(E,safeGet(j));function A(){return safeGet(P)||"imperial"}function D(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const R={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},y={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"},$=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(d){return $[Math.round(d/22.5)%16]}async function H(d){const v=safeGet(h);if(v)try{const B=JSON.parse(v);if(B.query===d)return B}catch{}const S=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(d)}&count=1&language=en&format=json`);if(!S.ok)throw new Error("Geocoding failed");const O=await S.json();if(!O.results||!O.results.length)throw new Error("Location not found");const m=O.results[0],f={query:d,lat:m.latitude,lon:m.longitude,city:m.name,state:m.admin1||"",country:m.country||"",countryCode:m.country_code||""};return safeSet(h,JSON.stringify(f)),f}function T(d){return d.countryCode==="US"&&d.state?`${d.city}, ${d.state}`:d.country?`${d.city}, ${d.country}`:d.city}async function z(d){try{const v=await H(d),S=A(),O=S==="metric"?"celsius":"fahrenheit",m=S==="metric"?"kmh":"mph",f=`https://api.open-meteo.com/v1/forecast?latitude=${v.lat}&longitude=${v.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${O}&wind_speed_unit=${m}`,B=await fetch(f);if(!B.ok)throw new Error("Weather fetch failed");const L=(await B.json()).current,o=L.weather_code;return{temp:Math.round(L.temperature_2m),condition:R[o]||"Unknown",icon:y[o]||"\u{1F324}\uFE0F",locationStr:T(v),windSpeed:Math.round(L.wind_speed_10m),windDir:k(L.wind_direction_10m),unit:S}}catch(v){return console.warn("Weather fetch failed:",v),null}}async function C(){const d=D();if(!d.icon||!d.temp||!d.condition||!d.location||!d.wind){console.warn("Weather widget elements not found");return}const v=safeGet(E);if(!v){d.location.textContent="Set Location",d.temp.textContent="--\xB0",d.condition.textContent="Click \u2699\uFE0F to configure",d.wind.textContent="--",d.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const S=await z(v);if(S){const O=S.unit==="metric"?"\xB0C":"\xB0F",m=S.unit==="metric"?"km/h":"mph";d.location.textContent=S.locationStr,d.temp.textContent=`${S.temp}${O}`,d.condition.textContent=S.condition,d.wind.textContent=`Wind: ${S.windSpeed} ${m} ${S.windDir}`,d.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(S.icon)}</span>`}}catch(S){x.logError("[Weather] Update Error",S,{function:"updateWeather"}),d.location.textContent="Weather Error",d.temp.textContent="Error",d.condition.textContent="Failed to load",d.wind.textContent="--"}}const M=document.getElementById("weather-modal"),b=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{b.value=safeGet(E)||"";const d=A(),v=M.querySelector(`input[name="weather-unit-radio"][value="${d}"]`);v&&(v.checked=!0),M.classList.add("show"),b.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const d=b.value.trim();if(d){safeGet(E)!==d&&safeSet(h,""),safeSet(E,d);const S=M.querySelector('input[name="weather-unit-radio"]:checked'),O=S?S.value:"imperial",m=A();safeSet(P,O),m!==O&&safeSet(h,""),M.classList.remove("show"),C()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(M),document.addEventListener("keydown",d=>{d.keyLine truncated
<circle cx="${i}" cy="${c}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F}
${_}
@@ -2034,7 +2034,7 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
<line x1="${i}" y1="${c}" x2="${i+38*Math.cos(N*Math.PI/180)}" y2="${c+38*Math.sin(N*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${i}" y1="${c}" x2="${i+42*Math.cos(I*Math.PI/180)}" y2="${c+42*Math.sin(I*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${i}" cy="${c}" r="3" fill="var(--fg)"/>
</svg>`,J=t.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(n).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${z(t)}</span></div></div>`,$="analog"}function f(){const t=new Date,e=t.getHours()%12||12,s=t.getMinutes(),n=t.getSeconds(),r="clock-widget"+(A!=="default"?" "+A:"");switch(x.className!==r&&(x.className=r),A){case"lcd":d(t);break;case"lcd-blue":d(t);break;case"lcd-amber":d(t);break;case"lcd-retro":d(t);break;case"lcd-taxi":d(t);break;case"flip":v(t);break;case"binary":O(t);break;case"analog":m(t,!1);break;case"roman":m(t,!0);break;default:b(t)}s===0&&n===0&&e!==D&&(D=e,T(e)),s!==0&&(D=-1)}function B(){clearTimeout(M);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;M=setTimeout(()=>{f(),B()},e)}document.addEventListener("visibilitychange",()=>{g="",C(),f(),B()}),f(),B();const w=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let L='<div class="clock-style-grid">';w.forEach(t=>{L+=`<label class="clock-style-option">
</svg>`,J=t.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(n).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${z(t)}</span></div></div>`,$="analog"}function f(){const t=new Date,e=t.getHours()%12||12,s=t.getMinutes(),n=t.getSeconds(),r="clock-widget"+(A!=="default"?" "+A:"");switch(x.className!==r&&(x.className=r),A){case"lcd":d(t);break;case"lcd-blue":d(t);break;case"lcd-amber":d(t);break;case"lcd-retro":d(t);break;case"lcd-taxi":d(t);break;case"flip":v(t);break;case"binary":O(t);break;case"analog":m(t,!1);break;case"roman":m(t,!0);break;default:b(t)}s===0&&n===0&&e!==D&&(D=e,T(e)),s!==0&&(D=-1)}function B(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{f(),B()},e)}document.addEventListener("visibilitychange",()=>{y="",C(),f(),B()}),f(),B();const w=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let L='<div class="clock-style-grid">';w.forEach(t=>{L+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${t.id}">
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
</label>`}),L+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
@@ -2066,7 +2066,7 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
<button id="clock-settings-save">Save</button>
</div>
</div>
</div>`);const o=document.getElementById("clock-settings-modal"),p=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),y=document.getElementById("clock-volume-section");function u(){const t=safeGet("clock-style")||"default",e=o.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),p.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",y.style.opacity=p.checked?"1":"0.4"}p?.addEventListener("change",()=>{y.style.opacity=p.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),o.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=o.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(p.checked)),safeSet("clock-chime-volume",l.value),A=e,g="",C(),f(),B(),o.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{o.classList.remove("show")}),wireModal(o),o?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{A=t.value,g="",C(),f()})})})(),(function(){const x=new ErrorHandler;injectModal("calendar-modal",`<div id="calendar-modal" class="weather-modal">
</div>`);const o=document.getElementById("clock-settings-modal"),u=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),g=document.getElementById("clock-volume-section");function p(){const t=safeGet("clock-style")||"default",e=o.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),u.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",g.style.opacity=u.checked?"1":"0.4"}u?.addEventListener("change",()=>{g.style.opacity=u.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{p(),o.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=o.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(u.checked)),safeSet("clock-chime-volume",l.value),A=e,y="",C(),f(),B(),o.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{o.classList.remove("show")}),wireModal(o),o?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{A=t.value,y="",C(),f()})})})(),(function(){const x=new ErrorHandler;injectModal("calendar-modal",`<div id="calendar-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width:560px;max-width:720px;">
<h3>\u{1F4C5} Calendar</h3>
<div id="calendar-upcoming" style="margin:0 0 14px;">
@@ -2112,9 +2112,9 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
</details>
</div>
<div class="weather-modal-buttons"><button id="calendar-close">Close</button></div>
</div></div>`);const E=()=>({icon:document.querySelector(".calendar-widget-icon"),text:document.querySelector(".calendar-widget-text")});function R(b,d){const v=new Date(b),S=new Date;S.setHours(0,0,0,0);const O=new Date(v);O.setHours(0,0,0,0);const m=Math.round((O-S)/864e5),f=m===0?"Today":m===1?"Tomorrow":v.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),B=d?"all-day":v.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${f} ${B}`}async function h(){const b=new Date(Date.now()-864e5),d=new Date(Date.now()+14*864e5),v=await fetch(`/api/v1/calendar/events?start=${b.toISOString()}&end=${d.toISOString()}`,{cache:"no-store"});if(v.status===404)return{notConfigured:!0};if(!v.ok)throw new Error(`Calendar API HTTP ${v.status}`);return v.json()}function P(b){const d=E();if(!d.icon||!d.text)return;const v=b&&Array.isArray(b.events)?b.events:[];if(!v.length){d.icon.textContent="\u{1F4C5}",d.text.textContent="No calendar";return}const S=v[0];d.icon.textContent=S.allDay?"\u{1F5D3}\uFE0F":"\u23F0",d.text.innerHTML=`${escapeHtml(S.title||"Untitled")}
<span style="color:var(--muted);font-size:0.78em;">\xB7 ${escapeHtml(R(S.start,S.allDay))}</span>`}function A(b){const d=document.getElementById("calendar-upcoming-list");if(!d)return;const v=b&&Array.isArray(b.events)?b.events:[];if(!v.length){d.innerHTML='<div style="color:var(--muted);font-size:0.85rem;">No upcoming appointments.</div>';return}d.innerHTML=v.slice(0,12).map(S=>`<div style="display:flex;gap:8px;align-items:baseline;">
<span style="font-size:0.78rem;color:var(--muted);white-space:nowrap;">${escapeHtml(R(S.start,S.allDay))}</span>
</div></div>`);const E=()=>({icon:document.querySelector(".calendar-widget-icon"),text:document.querySelector(".calendar-widget-text")});function j(b,d){const v=new Date(b),S=new Date;S.setHours(0,0,0,0);const O=new Date(v);O.setHours(0,0,0,0);const m=Math.round((O-S)/864e5),f=m===0?"Today":m===1?"Tomorrow":v.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),B=d?"all-day":v.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${f} ${B}`}async function h(){const b=new Date(Date.now()-864e5),d=new Date(Date.now()+14*864e5),v=await fetch(`/api/v1/calendar/events?start=${b.toISOString()}&end=${d.toISOString()}`,{cache:"no-store"});if(v.status===404)return{notConfigured:!0};if(!v.ok)throw new Error(`Calendar API HTTP ${v.status}`);return v.json()}function P(b){const d=E();if(!d.icon||!d.text)return;const v=b&&Array.isArray(b.events)?b.events:[];if(!v.length){d.icon.textContent="\u{1F4C5}",d.text.textContent="No calendar";return}const S=v[0];d.icon.textContent=S.allDay?"\u{1F5D3}\uFE0F":"\u23F0",d.text.innerHTML=`${escapeHtml(S.title||"Untitled")}
<span style="color:var(--muted);font-size:0.78em;">\xB7 ${escapeHtml(j(S.start,S.allDay))}</span>`}function A(b){const d=document.getElementById("calendar-upcoming-list");if(!d)return;const v=b&&Array.isArray(b.events)?b.events:[];if(!v.length){d.innerHTML='<div style="color:var(--muted);font-size:0.85rem;">No upcoming appointments.</div>';return}d.innerHTML=v.slice(0,12).map(S=>`<div style="display:flex;gap:8px;align-items:baseline;">
<span style="font-size:0.78rem;color:var(--muted);white-space:nowrap;">${escapeHtml(j(S.start,S.allDay))}</span>
<span style="font-size:0.85rem;">${escapeHtml(S.title||"Untitled")}</span>
${S.location?`<span style="font-size:0.75rem;color:var(--muted);">${escapeHtml(S.location)}</span>`:""}
</div>`).join("")}async function D(){const b=document.getElementById("calendar-sources-list");if(!b)return;let d=[];try{const v=await fetch("/api/v1/calendar/sources",{cache:"no-store"});if(v.ok){const S=await v.json();d=S&&Array.isArray(S.sources)?S.sources:Array.isArray(S)?S:[]}}catch{}if(!d.length){b.innerHTML='<div style="font-size:0.82rem;color:var(--muted);">No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.</div>';return}b.innerHTML=d.map(v=>`
@@ -2124,7 +2124,7 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
<button class="cal-src-toggle" data-id="${escapeHtml(v.id)}" data-enabled="${v.enabled!==!1?"1":"0"}"
style="font-size:0.75rem;padding:2px 8px;">${v.enabled!==!1?"\u2713 on":"off"}</button>
<button class="cal-src-del" data-id="${escapeHtml(v.id)}" style="font-size:0.75rem;padding:2px 8px;color:var(--err-fg);">remove</button>
</div>`).join(""),b.querySelectorAll(".cal-src-toggle").forEach(v=>v.addEventListener("click",async()=>{const S=v.dataset.id,O=v.dataset.enabled!=="1";await fetch(`/api/v1/calendar/sources/${encodeURIComponent(S)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:O})}),D(),j()})),b.querySelectorAll(".cal-src-del").forEach(v=>v.addEventListener("click",async()=>{const S=v.dataset.id;await fetch(`/api/v1/calendar/sources/${encodeURIComponent(S)}`,{method:"DELETE"}),D(),j()}))}async function j(){if(!j._inFlight){j._inFlight=!0;try{const b=await h();P(b),document.getElementById("calendar-modal").style.display==="flex"&&A(b)}catch(b){x.log(b,"calendar-widget");const d=E();d.text&&(d.text.textContent="Calendar offline")}finally{j._inFlight=!1}}}function g(){document.getElementById("calendar-modal").style.display="flex",D(),j()}function $(){document.getElementById("calendar-modal").style.display="none"}function k(){return{type:document.getElementById("cal-add-type").value,name:document.getElementById("cal-add-name").value.trim(),url:document.getElementById("cal-add-url").value.trim(),user:document.getElementById("cal-add-user").value.trim(),pass:document.getElementById("cal-add-pass").value}}function M(b){document.getElementById("cal-add-status").textContent=b}document.getElementById("cal-add-test").addEventListener("click",async()=>{const b=k();if(!b.url){M("Enter a URL first.");return}M("Testing\u2026");try{const v=await(await fetch("/api/v1/calendar/discover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:b.url,user:b.user,pass:b.pass})})).json(),S=v&&v.probe;S&&S.ok?M(`\u2713 Reachable (${S.server||"server"})${S.looksLikeCalendar?" \u2014 looks like a calendar":""}`):M(`\u2717 ${S&&S.error?S.error:"Not reachable"}`)}catch(d){M(`\u2717 ${d.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const b=k();if(!b.name||!b.url){M("Name and URL are required.");return}try{const d=await fetch("/api/v1/calendar/sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:b.name,type:b.type,url:b.url,user:b.user,pass:b.pass})});if(!d.ok){const v=await d.json().catch(()=>({}));M(`\u2717 ${v.error||v.message||`HTTP ${d.status}`}`);return}M("Saved."),document.getElementById("cal-add-details").open=!1,["cal-add-name","cal-add-url","cal-add-user","cal-add-pass"].forEach(v=>{document.getElementById(v).value=""}),D(),j()}catch(d){M(`\u2717 ${d.message}`)}});const T=document.getElementById("calendar-widget");T&&T.addEventListener("click",g);const z=document.getElementById("calendar-settings");z&&z.addEventListener("click",b=>{b.stopPropagation(),g()});const C=document.getElementById("calendar-close");C&&C.addEventListener("click",$),window.refreshCalendarWidget=j;const H=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(j,H),setTimeout(j,400)})(),(function(){const x=new ErrorHandler;injectModal("ai-connect-modal",`<div id="ai-connect-modal" class="weather-modal">
</div>`).join(""),b.querySelectorAll(".cal-src-toggle").forEach(v=>v.addEventListener("click",async()=>{const S=v.dataset.id,O=v.dataset.enabled!=="1";await fetch(`/api/v1/calendar/sources/${encodeURIComponent(S)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:O})}),D(),R()})),b.querySelectorAll(".cal-src-del").forEach(v=>v.addEventListener("click",async()=>{const S=v.dataset.id;await fetch(`/api/v1/calendar/sources/${encodeURIComponent(S)}`,{method:"DELETE"}),D(),R()}))}async function R(){if(!R._inFlight){R._inFlight=!0;try{const b=await h();P(b),document.getElementById("calendar-modal").style.display==="flex"&&A(b)}catch(b){x.log(b,"calendar-widget");const d=E();d.text&&(d.text.textContent="Calendar offline")}finally{R._inFlight=!1}}}function y(){document.getElementById("calendar-modal").style.display="flex",D(),R()}function $(){document.getElementById("calendar-modal").style.display="none"}function k(){return{type:document.getElementById("cal-add-type").value,name:document.getElementById("cal-add-name").value.trim(),url:document.getElementById("cal-add-url").value.trim(),user:document.getElementById("cal-add-user").value.trim(),pass:document.getElementById("cal-add-pass").value}}function H(b){document.getElementById("cal-add-status").textContent=b}document.getElementById("cal-add-test").addEventListener("click",async()=>{const b=k();if(!b.url){H("Enter a URL first.");return}H("Testing\u2026");try{const v=await(await fetch("/api/v1/calendar/discover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:b.url,user:b.user,pass:b.pass})})).json(),S=v&&v.probe;S&&S.ok?H(`\u2713 Reachable (${S.server||"server"})${S.looksLikeCalendar?" \u2014 looks like a calendar":""}`):H(`\u2717 ${S&&S.error?S.error:"Not reachable"}`)}catch(d){H(`\u2717 ${d.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const b=k();if(!b.name||!b.url){H("Name and URL are required.");return}try{const d=await fetch("/api/v1/calendar/sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:b.name,type:b.type,url:b.url,user:b.user,pass:b.pass})});if(!d.ok){const v=await d.json().catch(()=>({}));H(`\u2717 ${v.error||v.message||`HTTP ${d.status}`}`);return}H("Saved."),document.getElementById("cal-add-details").open=!1,["cal-add-name","cal-add-url","cal-add-user","cal-add-pass"].forEach(v=>{document.getElementById(v).value=""}),D(),R()}catch(d){H(`\u2717 ${d.message}`)}});const T=document.getElementById("calendar-widget");T&&T.addEventListener("click",y);const z=document.getElementById("calendar-settings");z&&z.addEventListener("click",b=>{b.stopPropagation(),y()});const C=document.getElementById("calendar-close");C&&C.addEventListener("click",$),window.refreshCalendarWidget=R;const M=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(R,M),setTimeout(R,400)})(),(function(){const x=new ErrorHandler;injectModal("ai-connect-modal",`<div id="ai-connect-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width:560px;max-width:720px;">
<h3>\u{1F916} Connect your AI assistant</h3>
<div id="aiconn-intro" style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">
@@ -2178,12 +2178,12 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
<span id="aiconn-status" style="flex:1;font-size:0.78rem;color:var(--muted);align-self:center;"></span>
<button id="aiconn-close">Close</button>
</div>
</div></div>`);const E=g=>document.getElementById(g);let R=null;function h(g){E("aiconn-status").textContent=g}async function P(){try{const g=await secureFetch("/api/v1/mcp/info");if(!g.ok)throw new Error(`HTTP ${g.status}`);const $=await g.json();R=$,E("aiconn-endpoint").style.display="block",E("aiconn-endpoint-url").textContent=$.endpoint,E("aiconn-tools").textContent=`${$.toolCount} tools \xB7 MCP ${$.protocolVersion} \xB7 DashCaddy v${$.serverVersion}`}catch(g){x.log(g,"ai-connect-info"),h("MCP endpoint unavailable on this build.")}}async function A(){const g=E("aiconn-connections");try{const k=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),M=k&&Array.isArray(k.connections)?k.connections:[];if(!M.length){g.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}g.innerHTML=M.map(T=>`
</div></div>`);const E=y=>document.getElementById(y);let j=null;function h(y){E("aiconn-status").textContent=y}async function P(){try{const y=await secureFetch("/api/v1/mcp/info");if(!y.ok)throw new Error(`HTTP ${y.status}`);const $=await y.json();j=$,E("aiconn-endpoint").style.display="block",E("aiconn-endpoint-url").textContent=$.endpoint,E("aiconn-tools").textContent=`${$.toolCount} tools \xB7 MCP ${$.protocolVersion} \xB7 DashCaddy v${$.serverVersion}`}catch(y){x.log(y,"ai-connect-info"),h("MCP endpoint unavailable on this build.")}}async function A(){const y=E("aiconn-connections");try{const k=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),H=k&&Array.isArray(k.connections)?k.connections:[];if(!H.length){y.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}y.innerHTML=H.map(T=>`
<div style="display:flex;gap:8px;align-items:center;font-size:0.8rem;">
<span style="flex:1;">${escapeHtml(T.name)}
<span style="color:var(--muted);">\xB7 ${(T.scopes||[]).join(",")} \xB7 since ${escapeHtml((T.createdAt||"").slice(0,10))}${T.lastUsed?" \xB7 last used "+escapeHtml(String(T.lastUsed).slice(0,10)):""}</span></span>
<button class="aiconn-revoke" data-id="${escapeHtml(T.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button>
</div>`).join(""),g.querySelectorAll(".aiconn-revoke").forEach(T=>T.addEventListener("click",async()=>{T.disabled=!0;try{const z=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(T.dataset.id)}`,{method:"DELETE"});if(z.ok)h("Connection revoked."),A();else{const C=await z.json().catch(()=>({}));h(C.error||"Revoke failed."),T.disabled=!1}}catch(z){h("Revoke failed: "+z.message),T.disabled=!1}}))}catch{g.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function D(g){h(`Creating ${g} connection\u2026`);try{const $=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:g})}),k=await $.json().catch(()=>({}));if(!$.ok||!k.key){h(k.error||`Failed (HTTP ${$.status})`);return}E("aiconn-key").textContent=k.key;const M=k.httpConfig?JSON.stringify(k.httpConfig,null,2):"";E("aiconn-snippet").textContent=M||"(see note below)",E("aiconn-client-note").textContent=k.note||"",E("aiconn-config-title").textContent=g==="chatgpt"?"Access key (ChatGPT note below):":"Your access key (shown once):",E("aiconn-step-pick").style.display="none",E("aiconn-step-config").style.display="block",h(""),A()}catch($){x.log($,"ai-connect-create"),h("Failed: "+$.message)}}function j(g,$){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(g).then(()=>h($)).catch(()=>h("Copy failed \u2014 select manually.")):h("Clipboard unavailable \u2014 select manually.")}E("ai-connect-btn").addEventListener("click",()=>{E("ai-connect-modal").style.display="flex",E("aiconn-step-pick").style.display="block",E("aiconn-step-config").style.display="none",h(""),P(),A()}),E("aiconn-close").addEventListener("click",()=>{E("ai-connect-modal").style.display="none"}),E("aiconn-back").addEventListener("click",()=>{E("aiconn-step-config").style.display="none",E("aiconn-step-pick").style.display="block"}),document.querySelectorAll(".aiconn-client-btn").forEach(g=>g.addEventListener("click",()=>D(g.dataset.client))),E("aiconn-copy-key").addEventListener("click",()=>j(E("aiconn-key").textContent,"Key copied \u2014 paste it into your client.")),E("aiconn-copy-snippet").addEventListener("click",()=>j(E("aiconn-snippet").textContent,"Config copied.")),E("aiconn-copy-endpoint").addEventListener("click",()=>j(R?R.endpoint:E("aiconn-endpoint-url").textContent,"Endpoint copied."))})(),(function(){async function x(){try{const j=await(await fetch("/api/v1/health-checks/status")).json();if(!j.success||!j.status)return;for(const[g,$]of Object.entries(j.status)){const k=document.getElementById("uptime-"+g),M=document.getElementById("uptime-bar-"+g);if(!k)continue;const T=$.uptime?.["24h"];if(T!=null){const z=T.toFixed(1);k.textContent=`${z}% uptime`,k.className="uptime-chip",T>=99.9?k.classList.add("excellent"):T>=99?k.classList.add("good"):T>=95?k.classList.add("degraded"):k.classList.add("poor"),M&&(M.style.width=z+"%")}}}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 R=[];async function h(D){try{const g=await(await fetch("/api/v1/updates/available")).json();if(!g.success)return;document.querySelectorAll(".update-available-badge").forEach(k=>k.classList.remove("visible"));const $=g.updates||[];if(R=$,D&&$.length>0){const k=window._lastKnownUpdateCount||0;k>0&&$.length>k&&showNotification(`${$.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=$.length}if(!$.length)return;for(const k of $){const M=window.APPS||[];for(const T of M)if(T.containerId===k.containerId||T.id===k.containerName||T.name===k.containerName){if(E.has(T.id))break;const z=document.getElementById("update-badge-"+T.id),C=document.getElementById("update-btn-"+T.id);z&&(z.classList.add("visible"),z.title="Update available \u2014 click to open Update Management.",z.style.cursor="pointer",z.onclick=H=>{H.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(T.id)}),C&&(C.style.background="#f97316",C.style.borderColor="#f97316",C.style.boxShadow="0 0 6px #f9731688",C.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function P(){setTimeout(()=>{x(),h()},5e3),setInterval(()=>{x(),h(!0)},6e4)}const A=window.refreshAll;A&&(window.refreshAll=async function(){try{await A(),setTimeout(x,1e3)}catch(D){console.warn("[Card Badges] Error in refreshAll hook:",D.message)}}),P()})(),(function(){var x=null,E=null,R={},h={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},P=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"]Line truncated
</div>`).join(""),y.querySelectorAll(".aiconn-revoke").forEach(T=>T.addEventListener("click",async()=>{T.disabled=!0;try{const z=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(T.dataset.id)}`,{method:"DELETE"});if(z.ok)h("Connection revoked."),A();else{const C=await z.json().catch(()=>({}));h(C.error||"Revoke failed."),T.disabled=!1}}catch(z){h("Revoke failed: "+z.message),T.disabled=!1}}))}catch{y.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function D(y){h(`Creating ${y} connection\u2026`);try{const $=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:y})}),k=await $.json().catch(()=>({}));if(!$.ok||!k.key){h(k.error||`Failed (HTTP ${$.status})`);return}E("aiconn-key").textContent=k.key;const H=k.httpConfig?JSON.stringify(k.httpConfig,null,2):"";E("aiconn-snippet").textContent=H||"(see note below)",E("aiconn-client-note").textContent=k.note||"",E("aiconn-config-title").textContent=y==="chatgpt"?"Access key (ChatGPT note below):":"Your access key (shown once):",E("aiconn-step-pick").style.display="none",E("aiconn-step-config").style.display="block",h(""),A()}catch($){x.log($,"ai-connect-create"),h("Failed: "+$.message)}}function R(y,$){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(y).then(()=>h($)).catch(()=>h("Copy failed \u2014 select manually.")):h("Clipboard unavailable \u2014 select manually.")}E("ai-connect-btn").addEventListener("click",()=>{E("ai-connect-modal").style.display="flex",E("aiconn-step-pick").style.display="block",E("aiconn-step-config").style.display="none",h(""),P(),A()}),E("aiconn-close").addEventListener("click",()=>{E("ai-connect-modal").style.display="none"}),E("aiconn-back").addEventListener("click",()=>{E("aiconn-step-config").style.display="none",E("aiconn-step-pick").style.display="block"}),document.querySelectorAll(".aiconn-client-btn").forEach(y=>y.addEventListener("click",()=>D(y.dataset.client))),E("aiconn-copy-key").addEventListener("click",()=>R(E("aiconn-key").textContent,"Key copied \u2014 paste it into your client.")),E("aiconn-copy-snippet").addEventListener("click",()=>R(E("aiconn-snippet").textContent,"Config copied.")),E("aiconn-copy-endpoint").addEventListener("click",()=>R(j?j.endpoint:E("aiconn-endpoint-url").textContent,"Endpoint copied."))})(),(function(){async function x(){try{const R=await(await fetch("/api/v1/health-checks/status")).json();if(!R.success||!R.status)return;for(const[y,$]of Object.entries(R.status)){const k=document.getElementById("uptime-"+y),H=document.getElementById("uptime-bar-"+y);if(!k)continue;const T=$.uptime?.["24h"];if(T!=null){const z=T.toFixed(1);k.textContent=`${z}% uptime`,k.className="uptime-chip",T>=99.9?k.classList.add("excellent"):T>=99?k.classList.add("good"):T>=95?k.classList.add("degraded"):k.classList.add("poor"),H&&(H.style.width=z+"%")}}}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 j=[];async function h(D){try{const y=await(await fetch("/api/v1/updates/available")).json();if(!y.success)return;document.querySelectorAll(".update-available-badge").forEach(k=>k.classList.remove("visible"));const $=y.updates||[];if(j=$,D&&$.length>0){const k=window._lastKnownUpdateCount||0;k>0&&$.length>k&&showNotification(`${$.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=$.length}if(!$.length)return;for(const k of $){const H=window.APPS||[];for(const T of H)if(T.containerId===k.containerId||T.id===k.containerName||T.name===k.containerName){if(E.has(T.id))break;const z=document.getElementById("update-badge-"+T.id),C=document.getElementById("update-btn-"+T.id);z&&(z.classList.add("visible"),z.title="Update available \u2014 click to open Update Management.",z.style.cursor="pointer",z.onclick=M=>{M.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(T.id)}),C&&(C.style.background="#f97316",C.style.borderColor="#f97316",C.style.boxShadow="0 0 6px #f9731688",C.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function P(){setTimeout(()=>{x(),h()},5e3),setInterval(()=>{x(),h(!0)},6e4)}const A=window.refreshAll;A&&(window.refreshAll=async function(){try{await A(),setTimeout(x,1e3)}catch(D){console.warn("[Card Badges] Error in refreshAll hook:",D.message)}}),P()})(),(function(){var x=null,E=null,j={},h={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},P=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"]Line truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -2220,16 +2220,16 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
<button id="license-activate" class="btn-accent">Activate</button>
</div>
</div>
</div>`);const x=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),R=document.getElementById("license-activate"),h=document.getElementById("license-deactivate"),P=document.getElementById("license-error"),A=document.getElementById("license-success"),D=document.getElementById("license-badge-icon"),j=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),$=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),M=document.getElementById("license-activate-section");let T=null;function z(){P.style.display="none",A.style.display="none"}function C(B){z(),P.textContent=B,P.style.display="block"}function H(B){z(),A.textContent=B,A.style.display="block"}function b(B){if(T=B,B.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",D.textContent="\u2605",j.textContent="Premium Active";const o=B.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(B.expiresAt).toLocaleDateString()}</strong> (${B.daysRemaining} days remaining)</div>`;$.innerHTML=`
</div>`);const x=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),j=document.getElementById("license-activate"),h=document.getElementById("license-deactivate"),P=document.getElementById("license-error"),A=document.getElementById("license-success"),D=document.getElementById("license-badge-icon"),R=document.getElementById("license-badge-text"),y=document.getElementById("license-badge"),$=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let T=null;function z(){P.style.display="none",A.style.display="none"}function C(B){z(),P.textContent=B,P.style.display="block"}function M(B){z(),A.textContent=B,A.style.display="block"}function b(B){if(T=B,B.active){y.style.background="rgba(46,204,113,0.15)",y.style.color="var(--ok-fg)",D.textContent="\u2605",R.textContent="Premium Active";const o=B.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(B.expiresAt).toLocaleDateString()}</strong> (${B.daysRemaining} days remaining)</div>`;$.innerHTML=`
<div>Code: <code style="font-family: monospace;">${B.code||"***"}</code></div>
${o}
`,M.style.display="none",R.style.display="none",h.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",D.textContent="\u2606",j.textContent=B.expired?"License Expired":"Free Tier",$.innerHTML=B.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",M.style.display="",R.style.display="",h.style.display="none";const w=B.premiumFeatures||{},L=new Set(B.features||[]);k.innerHTML=Object.entries(w).map(([o,p])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
`,H.style.display="none",j.style.display="none",h.style.display=""}else y.style.background="rgba(149,165,166,0.15)",y.style.color="var(--muted)",D.textContent="\u2606",R.textContent=B.expired?"License Expired":"Free Tier",$.innerHTML=B.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",j.style.display="",h.style.display="none";const w=B.premiumFeatures||{},L=new Set(B.features||[]);k.innerHTML=Object.entries(w).map(([o,u])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${L.has(o)?"\u2705":"\u{1F512}"}</span>
<div>
<div style="font-weight: 600; font-size: 0.9rem;">${p.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${p.description}</div>
<div style="font-weight: 600; font-size: 0.9rem;">${u.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${u.description}</div>
</div>
</div>`).join("")}async function d(){try{const w=await(await fetch("/api/v1/license/status")).json();w.success&&(b(w.license),O(w.license))}catch(B){console.warn("Failed to load license status:",B.message)}}async function v(){const B=E.value.trim();if(!B){C("Please enter a license code.");return}z(),R.disabled=!0,R.textContent="Activating...";try{const L=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:B})})).json();L.success?(H(L.message),E.value="",b(L.license),showNotification("License activated! Premium features unlocked.","success",5e3),O(L.license)):C(L.error||"Activation failed")}catch(w){C("Network error: "+w.message)}finally{R.disabled=!1,R.textContent="Activate"}}async function S(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){h.disabled=!0,h.textContent="Deactivating...";try{const w=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();w.success?(H(w.message),await d(),showNotification("License deactivated.","info",3e3),O({active:!1})):C(w.error||"Deactivation failed")}catch(B){C("Network error: "+B.message)}finally{h.disabled=!1,h.textContent="Deactivate"}}}function O(B){const w=document.getElementById("license-status-topbar"),L=document.getElementById("license-topbar-icon"),o=document.getElementById("license-topbar-text"),p=document.getElementById("license-topbar-time");if(w)if(w.className="license-status-topbar "+(B.active?"premium":"free"),B.active)if(L.textContent="\u2605",o.textContent="PREMIUM",B.lifetime)p.textContent="\xB7 LIFETIME";else{const l=B.daysRemaining;p.textContent=l!=null?"\xB7 "+l+"d remaining":""}else L.textContent="\u2606",o.textContent=B.expired?"EXPIRED":"FREE TIER",p.textContent=""}function m(){z(),d(),x.classList.add("show")}E.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 w=["DC"],L=B.substring(2);for(let o=0;o<L.length;o+=5)w.push(L.substring(o,o+5));B=w.join("-")}this._prevLength=B.length,this.value=B}),R.addEventListener("click",v),h.addEventListener("click",S),E.addEventListener("keydown",B=>{B.key==="Enter"&&v()}),wireModal(x,document.getElementById("license-cancel"));const f=document.getElementById("license-status-topbar");f&&f.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=m,window.checkPremiumFeature=async function(B){try{return(await(await fetch(`/api/v1/license/feature/${B}`)).json()).available}catch{return!1}},d().then(B=>{T&&O(T)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const x=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
</div>`).join("")}async function d(){try{const w=await(await fetch("/api/v1/license/status")).json();w.success&&(b(w.license),O(w.license))}catch(B){console.warn("Failed to load license status:",B.message)}}async function v(){const B=E.value.trim();if(!B){C("Please enter a license code.");return}z(),j.disabled=!0,j.textContent="Activating...";try{const L=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:B})})).json();L.success?(M(L.message),E.value="",b(L.license),showNotification("License activated! Premium features unlocked.","success",5e3),O(L.license)):C(L.error||"Activation failed")}catch(w){C("Network error: "+w.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function S(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){h.disabled=!0,h.textContent="Deactivating...";try{const w=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();w.success?(M(w.message),await d(),showNotification("License deactivated.","info",3e3),O({active:!1})):C(w.error||"Deactivation failed")}catch(B){C("Network error: "+B.message)}finally{h.disabled=!1,h.textContent="Deactivate"}}}function O(B){const w=document.getElementById("license-status-topbar"),L=document.getElementById("license-topbar-icon"),o=document.getElementById("license-topbar-text"),u=document.getElementById("license-topbar-time");if(w)if(w.className="license-status-topbar "+(B.active?"premium":"free"),B.active)if(L.textContent="\u2605",o.textContent="PREMIUM",B.lifetime)u.textContent="\xB7 LIFETIME";else{const l=B.daysRemaining;u.textContent=l!=null?"\xB7 "+l+"d remaining":""}else L.textContent="\u2606",o.textContent=B.expired?"EXPIRED":"FREE TIER",u.textContent=""}function m(){z(),d(),x.classList.add("show")}E.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 w=["DC"],L=B.substring(2);for(let o=0;o<L.length;o+=5)w.push(L.substring(o,o+5));B=w.join("-")}this._prevLength=B.length,this.value=B}),j.addEventListener("click",v),h.addEventListener("click",S),E.addEventListener("keydown",B=>{B.key==="Enter"&&v()}),wireModal(x,document.getElementById("license-cancel"));const f=document.getElementById("license-status-topbar");f&&f.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=m,window.checkPremiumFeature=async function(B){try{return(await(await fetch(`/api/v1/license/feature/${B}`)).json()).available}catch{return!1}},d().then(B=>{T&&O(T)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const x=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
@@ -2298,7 +2298,7 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
</div>
</div>
</div>
`);const E=document.getElementById("share-modal"),R=document.getElementById("share-modal-service-name"),h=document.getElementById("share-issued"),P=document.getElementById("share-issued-url"),A=document.getElementById("share-issued-copy"),D=document.getElementById("share-issued-meta"),j=document.getElementById("share-error"),g=document.getElementById("share-success"),$=document.getElementById("share-outstanding-list"),k=document.getElementById("share-cancel"),M=document.getElementById("share-public-create"),T=document.getElementById("share-ts-create"),z=document.getElementById("share-ts-email"),C=document.getElementById("share-public-ttl");let H=null,b="public";function d(){j.style.display="none",g.style.display="none",h.style.display="none"}function v(t){g.style.display="none",h.style.display="none",j.textContent=t,j.style.display="block"}function S(t){j.style.display="none",g.textContent=t,g.style.display="block"}function O(){return window.location.origin}function m(t){return t.startsWith("http")?t:O()+t}function f(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function B(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const s=Math.floor(t%36e5/6e4);return`${e}h ${s}m left`}function w(t){b=t,d(),E.querySelectorAll(".share-tab").forEach(e=>{const s=e.dataset.tab===t;e.classList.toggle("active",s),e.style.borderBottomColor=s?"var(--accent)":"transparent",e.style.color=s?"var(--fg)":"var(--muted)",e.style.fontWeight=s?"600":"400"}),E.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function L(){$.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){$.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),n=(e&&e.data||[]).filter(r=>r.serviceId===H.id);if(n.length===0){$.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}$.innerHTML=n.map(r=>{const a=r.expiresAt?B(new Date(r.expiresAt).getTime()-Date.now()):"";return`
`);const E=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),h=document.getElementById("share-issued"),P=document.getElementById("share-issued-url"),A=document.getElementById("share-issued-copy"),D=document.getElementById("share-issued-meta"),R=document.getElementById("share-error"),y=document.getElementById("share-success"),$=document.getElementById("share-outstanding-list"),k=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),T=document.getElementById("share-ts-create"),z=document.getElementById("share-ts-email"),C=document.getElementById("share-public-ttl");let M=null,b="public";function d(){R.style.display="none",y.style.display="none",h.style.display="none"}function v(t){y.style.display="none",h.style.display="none",R.textContent=t,R.style.display="block"}function S(t){R.style.display="none",y.textContent=t,y.style.display="block"}function O(){return window.location.origin}function m(t){return t.startsWith("http")?t:O()+t}function f(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function B(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const s=Math.floor(t%36e5/6e4);return`${e}h ${s}m left`}function w(t){b=t,d(),E.querySelectorAll(".share-tab").forEach(e=>{const s=e.dataset.tab===t;e.classList.toggle("active",s),e.style.borderBottomColor=s?"var(--accent)":"transparent",e.style.color=s?"var(--fg)":"var(--muted)",e.style.fontWeight=s?"600":"400"}),E.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function L(){$.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){$.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),n=(e&&e.data||[]).filter(r=>r.serviceId===M.id);if(n.length===0){$.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}$.innerHTML=n.map(r=>{const a=r.expiresAt?B(new Date(r.expiresAt).getTime()-Date.now()):"";return`
<div class="share-row" data-share-id="${escapeHtml(r.id)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;">
@@ -2310,5 +2310,5 @@ This can take ~30-60s\u2026`);try{const{body:v}=await P("/deploy",{method:"POST"
Revoke
</button>
</div>
`}).join(""),$.querySelectorAll(".share-revoke").forEach(r=>{r.addEventListener("click",()=>o(r.dataset.shareId))})}catch{$.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function o(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const s=await e.json().catch(()=>({}));v(s&&s.error||`Revoke failed (HTTP ${e.status}).`);return}S("Share revoked."),L()}catch{v("Network error while revoking.")}}async function p(){if(!H)return;d(),M.disabled=!0;const t=M.textContent;M.textContent="Creating\u2026";try{const e=parseInt(C.value,10),s=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:H.id,ttlMs:e})}),n=await s.json().catch(()=>({}));if(!s.ok||!n.success){y(s.status,n);return}const r=n.data.urlPath;P.value=m(r),D.textContent=`Public link \xB7 expires ${f(n.data.expiresAt)}`,h.style.display="block",S("Share link created."),L()}catch{v("Network error while creating share.")}finally{M.disabled=!1,M.textContent=t}}async function l(){if(!H)return;const t=(z.value||"").trim();if(!t||!t.includes("@")){v("A valid recipient email is required.");return}d(),T.disabled=!0;const e=T.textContent;T.textContent="Creating\u2026";try{const s=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:H.id,email:t})}),n=await s.json().catch(()=>({}));if(!s.ok||!n.success){y(s.status,n);return}const r=n.data&&n.data.urlPath;r?(P.value=m(r),D.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(P.value="",D.textContent=`Tailscale invite sent to ${t}.`),h.style.display="block",S("Tailscale invite created."),z.value="",L()}catch{v("Network error while creating Tailscale invite.")}finally{T.disabled=!1,T.textContent=e}}function y(t,e){const s=e&&e.error||"";if(t===402){v("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(s)){v("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){v("You do not have permission to share services.");return}if(t===404){v("Service not found. It may have been deleted.");return}v(s||`Request failed (HTTP ${t}).`)}function u(t){!t||!t.id||(H={id:t.id,name:t.name||t.id},R.textContent=H.name,d(),w("public"),z.value="",C.value=String(x[1].ms),E.classList.add("show"),L())}E.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>w(t.dataset.tab))}),M.addEventListener("click",p),T.addEventListener("click",l),k.addEventListener("click",()=>E.classList.remove("show")),wireModal(E,k),A.addEventListener("click",async()=>{const t=P.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(P.select(),document.execCommand("copy"));const e=A.textContent;A.textContent="Copied!",setTimeout(()=>{A.textContent=e},1200)}catch{v("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=u})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
`}).join(""),$.querySelectorAll(".share-revoke").forEach(r=>{r.addEventListener("click",()=>o(r.dataset.shareId))})}catch{$.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function o(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const s=await e.json().catch(()=>({}));v(s&&s.error||`Revoke failed (HTTP ${e.status}).`);return}S("Share revoked."),L()}catch{v("Network error while revoking.")}}async function u(){if(!M)return;d(),H.disabled=!0;const t=H.textContent;H.textContent="Creating\u2026";try{const e=parseInt(C.value,10),s=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:M.id,ttlMs:e})}),n=await s.json().catch(()=>({}));if(!s.ok||!n.success){g(s.status,n);return}const r=n.data.urlPath;P.value=m(r),D.textContent=`Public link \xB7 expires ${f(n.data.expiresAt)}`,h.style.display="block",S("Share link created."),L()}catch{v("Network error while creating share.")}finally{H.disabled=!1,H.textContent=t}}async function l(){if(!M)return;const t=(z.value||"").trim();if(!t||!t.includes("@")){v("A valid recipient email is required.");return}d(),T.disabled=!0;const e=T.textContent;T.textContent="Creating\u2026";try{const s=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:M.id,email:t})}),n=await s.json().catch(()=>({}));if(!s.ok||!n.success){g(s.status,n);return}const r=n.data&&n.data.urlPath;r?(P.value=m(r),D.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(P.value="",D.textContent=`Tailscale invite sent to ${t}.`),h.style.display="block",S("Tailscale invite created."),z.value="",L()}catch{v("Network error while creating Tailscale invite.")}finally{T.disabled=!1,T.textContent=e}}function g(t,e){const s=e&&e.error||"";if(t===402){v("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(s)){v("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){v("You do not have permission to share services.");return}if(t===404){v("Service not found. It may have been deleted.");return}v(s||`Request failed (HTTP ${t}).`)}function p(t){!t||!t.id||(M={id:t.id,name:t.name||t.id},j.textContent=M.name,d(),w("public"),z.value="",C.value=String(x[1].ms),E.classList.add("show"),L())}E.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>w(t.dataset.tab))}),H.addEventListener("click",u),T.addEventListener("click",l),k.addEventListener("click",()=>E.classList.remove("show")),wireModal(E,k),A.addEventListener("click",async()=>{const t=P.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(P.select(),document.execCommand("copy"));const e=A.textContent;A.textContent="Copied!",setTimeout(()=>{A.textContent=e},1200)}catch{v("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=p})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
+262 -85
View File
@@ -1,7 +1,53 @@
// AI Chat Panel — DashCaddy AI Intent Router frontend
(function() {
let chatMessages = [];
let chatHistory = [];
// AI Chat Panel — DashCaddy AI Intent Router frontend (DC-163)
//
// Fixes over the previous version:
// - Response parsing: the API returns a FLAT ok() envelope
// ({success:true, intent, response, appId, engine…}); the old code read
// data.data (nonexistent) so EVERY reply showed "Sorry, something went
// wrong". Now reads the real shape.
// - Suggestion chips: old inline onclick used nested double quotes —
// invalid JS, chips did nothing. Now data-attributes + listeners.
// - Deploy button: window.openAppSelector never existed; now calls
// window.openDeployConfigFor(id) added to app-selector.js (opens the
// real deploy modal, same flow as clicking the app in the catalog).
// - Conversation history (last turns) rides along so the LLM engine can
// resolve "yes" / "name it vault" / follow-ups.
// - Engine indicator: header shows live status from /ai/capabilities.
// - ⚙ Settings: configure the LLM (OpenAI-compatible baseUrl/model/key)
// stored server-side in the encrypted credential store; Test button
// proves it end-to-end. Keys never come back from the server.
// All server/model text renders via textContent (no HTML injection).
(function () {
let chatTurns = []; // [{role, content}] — transcript for LLM context
let engineInfo = { engine: 'pattern', llmConfigured: false };
const SUGGESTIONS = [
['Deploy Plex', 'plex'],
['Stream movies', 'I want to stream movies'],
['Block ads', 'Block ads on my network'],
['System health', 'Is everything OK?'],
];
function css() {
if (document.getElementById('ai-chat-css')) return;
const s = document.createElement('style');
s.id = 'ai-chat-css';
s.textContent = `
#ai-chat-panel .ai-chip { background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc; }
#ai-chat-panel .ai-chip:hover { background:rgba(99,102,241,0.28); }
#ai-chat-panel .ai-act { display:block; margin:4px 0; border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; }
#ai-chat-panel .ai-act.deploy { background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); color:#4ade80; }
#ai-chat-panel .ai-act.warn { background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.3); color:#fbbf24; }
#ai-chat-panel .ai-act.blue { background:rgba(99,102,241,0.12); border:1px solid rgba(99,102,241,0.3); color:#a5b4fc; }
#ai-chat-panel .ai-msg-user { align-self:flex-end; background:rgba(99,102,241,0.15); border-radius:10px 10px 2px 10px; padding:10px 14px; max-width:85%; font-size:0.85rem; }
#ai-chat-panel .ai-msg-ai { align-self:flex-start; background:rgba(255,255,255,0.05); border-radius:10px 10px 10px 2px; padding:10px 14px; max-width:85%; font-size:0.85rem; }
#ai-chat-panel .ai-note { font-size:0.7rem; color:var(--text-muted,#666); padding:4px 8px; line-height:1.4; }
#ai-chat-panel input.ai-in { flex:1; background:var(--bg-input,#0f0f1a); border:1px solid var(--border,#333); border-radius:8px; padding:10px 14px; color:var(--text,#fff); font-size:0.88rem; outline:none; }
`;
document.head.appendChild(s);
}
function esc(s) { return String(s == null ? '' : s); }
function createChatPanel() {
const existing = document.getElementById('ai-chat-panel');
@@ -9,110 +55,157 @@
const panel = document.createElement('div');
panel.id = 'ai-chat-panel';
panel.style.cssText = `
position: fixed; bottom: 80px; right: 20px; width: 400px; max-width: 95vw;
height: 500px; max-height: 70vh; background: var(--bg-card, #1a1a2e);
border: 1px solid var(--border, #333); border-radius: 12px;
display: none; flex-direction: column; z-index: 9999;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
`;
panel.style.cssText = 'position: fixed; bottom: 80px; right: 20px; width: 400px; max-width: 95vw; height: 520px; max-height: 70vh; background: var(--bg-card, #1a1a2e); border: 1px solid var(--border, #333); border-radius: 12px; display: none; flex-direction: column; z-index: 9999; box-shadow: 0 8px 32px rgba(0,0,0,0.4);';
panel.innerHTML = `
<div style="padding: 14px 18px; border-bottom: 1px solid var(--border,#333); display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.3rem;">🤖</span>
<div style="flex:1;">
<div style="font-weight: 700; font-size: 0.95rem;">AI Assistant</div>
<div id="ai-status" style="font-size: 0.72rem; color: var(--text-muted,#666);">Pattern matching • No key needed</div>
</div>
<button onclick="document.getElementById('ai-chat-panel').style.display='none'" style="background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-muted,#888);">×</button>
</div>
<div id="ai-chat-messages" style="flex:1; overflow-y:auto; padding:14px; display:flex; flex-direction:column; gap:10px;">
<div style="background:rgba(99,102,241,0.08); border-radius:10px; padding:12px; font-size:0.85rem; color:var(--text-muted,#aaa);">
👋 Hi! I can help you deploy apps, diagnose issues, and manage your server. Try:
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;">
<button class="ai-suggestion" onclick="window.aiChat.send('"Deploy Plex"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Deploy Plex</button>
<button class="ai-suggestion" onclick="window.aiChat.send('"I want to stream movies"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Stream movies</button>
<button class="ai-suggestion" onclick="window.aiChat.send('"Block ads on my network"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Block ads</button>
<button class="ai-suggestion" onclick="window.aiChat.send('"Is everything OK?')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">System health</button>
</div>
</div>
</div>
<div style="padding:12px; border-top:1px solid var(--border,#333);">
<div style="display:flex; gap:8px;">
<input id="ai-chat-input" type="text" placeholder="Ask me anything..."
onkeypress="if(event.key==='Enter') window.aiChat.send()"
style="flex:1; background:var(--bg-input,#0f0f1a); border:1px solid var(--border,#333); border-radius:8px; padding:10px 14px; color:var(--text,#fff); font-size:0.88rem; outline:none;">
<button onclick="window.aiChat.send()" style="background:#6366f1; border:none; border-radius:8px; padding:0 16px; color:#fff; cursor:pointer; font-weight:600;">→</button>
</div>
</div>
`;
const header = document.createElement('div');
header.style.cssText = 'padding: 14px 18px; border-bottom: 1px solid var(--border,#333); display: flex; align-items: center; gap: 10px;';
const icon = document.createElement('span');
icon.style.cssText = 'font-size:1.3rem;';
icon.textContent = '🤖';
const titles = document.createElement('div');
titles.style.cssText = 'flex:1;';
const t1 = document.createElement('div');
t1.style.cssText = 'font-weight: 700; font-size: 0.95rem;';
t1.textContent = 'AI Assistant';
const t2 = document.createElement('div');
t2.id = 'ai-status';
t2.style.cssText = 'font-size: 0.72rem; color: var(--text-muted,#666);';
t2.textContent = 'Loading…';
titles.appendChild(t1); titles.appendChild(t2);
const gear = document.createElement('button');
gear.id = 'ai-settings-btn';
gear.style.cssText = 'background:none;border:none;font-size:1.1rem;cursor:pointer;color:var(--text-muted,#888);';
gear.title = 'Assistant settings';
gear.textContent = '⚙';
const close = document.createElement('button');
close.style.cssText = 'background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-muted,#888);';
close.title = 'Close';
close.textContent = '×';
close.addEventListener('click', () => { panel.style.display = 'none'; });
header.appendChild(icon); header.appendChild(titles); header.appendChild(gear); header.appendChild(close);
const messages = document.createElement('div');
messages.id = 'ai-chat-messages';
messages.style.cssText = 'flex:1; overflow-y:auto; padding:14px; display:flex; flex-direction:column; gap:10px;';
const intro = document.createElement('div');
intro.style.cssText = 'background:rgba(99,102,241,0.08); border-radius:10px; padding:12px; font-size:0.85rem; color:var(--text-muted,#aaa);';
intro.textContent = '👋 Hi! I can help you deploy apps, diagnose issues, and manage your server. Try:';
const chips = document.createElement('div');
chips.style.cssText = 'margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;';
SUGGESTIONS.forEach(([label, text]) => {
const b = document.createElement('button');
b.className = 'ai-chip';
b.textContent = label;
b.addEventListener('click', () => send(text));
chips.appendChild(b);
});
intro.appendChild(chips);
messages.appendChild(intro);
const foot = document.createElement('div');
foot.style.cssText = 'padding:12px; border-top:1px solid var(--border,#333);';
const row = document.createElement('div');
row.style.cssText = 'display:flex; gap:8px;';
const input = document.createElement('input');
input.id = 'ai-chat-input';
input.type = 'text';
input.placeholder = 'Ask me anything...';
input.className = 'ai-in';
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
const go = document.createElement('button');
go.style.cssText = 'background:#6366f1; border:none; border-radius:8px; padding:0 16px; color:#fff; cursor:pointer; font-weight:600;';
go.textContent = '→';
go.addEventListener('click', () => send());
row.appendChild(input); row.appendChild(go);
foot.appendChild(row);
panel.appendChild(header); panel.appendChild(messages); panel.appendChild(foot);
document.body.appendChild(panel);
}
function toggle() {
let panel = document.getElementById('ai-chat-panel');
if (!panel) { createChatPanel(); panel = document.getElementById('ai-chat-panel'); }
if (!panel) { css(); createChatPanel(); panel = document.getElementById('ai-chat-panel'); refreshEngineBadge(); }
panel.style.display = panel.style.display === 'flex' ? 'none' : 'flex';
if (panel.style.display === 'flex') document.getElementById('ai-chat-input').focus();
}
async function refreshEngineBadge() {
try {
const res = await fetch('/api/v1/ai/capabilities');
const data = await res.json();
if (data && data.success) {
engineInfo = { engine: data.engine, llmConfigured: Boolean(data.llm && data.llm.configured) };
const el = document.getElementById('ai-status');
if (el) el.textContent = engineInfo.llmConfigured
? 'LLM engine: ' + (data.llm.model || 'configured')
: 'Pattern engine (offline) — ⚙ to add an LLM';
}
} catch (_) { /* badge stays */ }
}
function addMessage(role, text, extra) {
const container = document.getElementById('ai-chat-messages');
if (!container) return;
const div = document.createElement('div');
const isUser = role === 'user';
div.style.cssText = isUser
? 'align-self:flex-end; background:rgba(99,102,241,0.15); border-radius:10px 10px 2px 10px; padding:10px 14px; max-width:85%; font-size:0.85rem;'
: 'align-self:flex-start; background:rgba(255,255,255,0.05); border-radius:10px 10px 10px 2px; padding:10px 14px; max-width:85%; font-size:0.85rem;';
div.className = role === 'user' ? 'ai-msg-user' : 'ai-msg-ai';
div.textContent = text;
container.appendChild(div);
chatTurns.push({ role: role === 'user' ? 'user' : 'assistant', content: text });
if (chatTurns.length > 12) chatTurns = chatTurns.slice(-12);
// Extra content (recommendations, action buttons)
if (extra) {
const extraDiv = document.createElement('div');
extraDiv.style.cssText = 'align-self:flex-start; max-width:85%; margin-top:4px;';
if (extra.engine === 'llm-error' || extra.llmError) {
const n = document.createElement('div');
n.className = 'ai-note';
n.textContent = '⚠ LLM unavailable — answered with the offline pattern engine.';
extraDiv.appendChild(n);
}
if (extra.disclaimer) {
const disc = document.createElement('div');
disc.style.cssText = 'font-size:0.7rem; color:var(--text-muted,#666); padding:6px 8px; line-height:1.4; margin-top:4px;';
disc.textContent = '⚠️ ' + extra.disclaimer;
disc.className = 'ai-note';
disc.textContent = '⚠ ' + esc(extra.disclaimer);
extraDiv.appendChild(disc);
}
if (extra.recommendations) {
extra.recommendations.forEach(rec => {
if (Array.isArray(extra.recommendations)) {
extra.recommendations.forEach((rec) => {
const btn = document.createElement('button');
btn.textContent = `📦 Deploy ${rec.app}`;
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
btn.onclick = () => window.aiChat.send('Deploy ' + rec.app);
btn.className = 'ai-act deploy';
btn.textContent = `📦 Deploy ${esc(rec.app)} — ${esc(rec.reason || '')}`;
btn.addEventListener('click', () => send('Deploy ' + rec.app));
extraDiv.appendChild(btn);
});
}
if (extra.appId) {
const btn = document.createElement('button');
btn.textContent = `🚀 Deploy ${extra.appId}`;
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
btn.onclick = () => window.aiChat.deploy(extra.appId);
btn.className = 'ai-act deploy';
btn.textContent = `🚀 Deploy ${esc(extra.appName || extra.appId)}`;
btn.addEventListener('click', () => deploy(extra.appId));
extraDiv.appendChild(btn);
}
if (extra.suggestions) {
const list = document.createElement('div');
list.style.cssText = 'font-size:0.78rem; color:var(--text-muted,#888); padding:4px 0;';
list.innerHTML = extra.suggestions.map(s => '• ' + s).join('<br>');
extraDiv.appendChild(list);
}
if (extra.action === 'diagnose') {
const btn = document.createElement('button');
btn.className = 'ai-act warn';
btn.textContent = '🔍 Run Diagnostics';
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#fbbf24;';
btn.onclick = () => window.location.href = '#health';
btn.addEventListener('click', () => { window.location.href = '#health'; });
extraDiv.appendChild(btn);
}
if (extra.action === 'backup') {
const btn = document.createElement('button');
btn.className = 'ai-act blue';
btn.textContent = '💾 Create Backup';
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(99,102,241,0.12); border:1px solid rgba(99,102,241,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#a5b4fc;';
btn.onclick = () => { if (window.createBackup) window.createBackup(); };
btn.addEventListener('click', () => { if (window.createBackup) window.createBackup(); });
extraDiv.appendChild(btn);
}
if (Array.isArray(extra.suggestions)) {
const list = document.createElement('div');
list.className = 'ai-note';
list.textContent = 'Try: ' + extra.suggestions.join(' · ');
extraDiv.appendChild(list);
}
container.appendChild(extraDiv);
}
container.scrollTop = container.scrollHeight;
@@ -120,16 +213,14 @@
async function send(prefilled) {
const input = document.getElementById('ai-chat-input');
const message = prefilled || input.value.trim();
if (!message) return;
input.value = '';
const raw = prefilled || (input && input.value.trim()) || '';
if (!raw) return;
if (input) input.value = '';
addMessage('user', message);
addMessage('user', raw);
// Typing indicator
const container = document.getElementById('ai-chat-messages');
const typing = document.createElement('div');
typing.id = 'ai-typing';
typing.style.cssText = 'align-self:flex-start; color:var(--text-muted,#666); font-size:0.8rem; padding:8px 14px;';
typing.textContent = '🤖 Thinking...';
container.appendChild(typing);
@@ -139,17 +230,31 @@
const res = await secureFetch('/api/v1/ai/intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, context: {} })
// history: prior turns EXCLUDING the just-added user line
body: JSON.stringify({ message: raw.slice(0, 1000), history: chatTurns.slice(0, -1) }),
});
const data = await res.json();
typing.remove();
if (data.success !== false && data.data) {
const r = data.data;
const response = r.response || {};
addMessage('ai', response.message || r.response?.message || 'Action: ' + (r.action || r.intent), response);
if (data && data.success !== false && data.intent) {
const resp = data.response || {};
const text = resp.message || data.llmReply || ('Action: ' + (data.action || data.intent));
addMessage('ai', text, {
...resp,
engine: data.engine,
llmError: data.llmError,
appId: data.appId || resp.appId,
appName: data.appName || resp.appName,
action: data.intent,
suggestions: resp.suggestions,
});
const badge = document.getElementById('ai-status');
if (badge && data.engine === 'llm') {
// remember live engine flip (config could have just been set)
engineInfo.engine = 'llm';
}
} else {
addMessage('ai', data.error || 'Sorry, something went wrong.');
addMessage('ai', (data && data.error) || 'Sorry, something went wrong.');
}
} catch (e) {
typing.remove();
@@ -158,16 +263,88 @@
}
async function deploy(appId) {
addMessage('user', 'Deploy ' + appId);
addMessage('ai', `Launching ${appId} from the catalog...`);
// Trigger the app selector with this app
if (window.openAppSelector) {
window.openAppSelector(appId);
} else {
addMessage('ai', `Search for ${appId} in the App Catalog to deploy it.`);
if (typeof window.openDeployConfigFor === 'function') {
const r = await window.openDeployConfigFor(appId);
if (r === true) { addMessage('ai', `Opened the deploy setup for ${appId} — pick a subdomain and confirm.`); return; }
if (r === 'selector') { addMessage('ai', `${appId} is a dashboard widget — open it from the app grid to toggle it.`); return; }
}
addMessage('ai', `Template "${appId}" not found in the catalog. Open the + menu to browse apps.`);
}
// ── ⚙ settings modal ──────────────────────────────────────────────────────
function openSettings() {
const old = document.getElementById('ai-llm-settings-modal');
if (old) old.remove();
const m = document.createElement('div');
m.id = 'ai-llm-settings-modal';
m.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:10000; display:flex; align-items:center; justify-content:center;';
m.innerHTML = `
<div style="background:var(--bg-card,#16162a); border:1px solid var(--border,#333); border-radius:12px; padding:20px; width:420px; max-width:92vw; display:flex; flex-direction:column; gap:10px;">
<div style="font-weight:700;">AI assistant — LLM engine</div>
<div class="ai-note" style="color:var(--text-muted,#888); font-size:0.75rem;">Any OpenAI-compatible endpoint. Stored in the encrypted credential store (never in browser-exposed config). Leave the key blank to keep the stored one. Without an LLM the assistant still works on the offline pattern engine.</div>
<label style="font-size:0.78rem;">Base URL <input id="ai-cfg-base" class="ai-in" placeholder="https://api.example.com/v1" style="width:100%;box-sizing:border-box;"></label>
<label style="font-size:0.78rem;">Model <input id="ai-cfg-model" class="ai-in" placeholder="gpt-4o-mini"></label>
<label style="font-size:0.78rem;">API key <input id="ai-cfg-key" class="ai-in" type="password" autocomplete="off" placeholder="stored key unchanged"></label>
<div id="ai-cfg-state" class="ai-note"></div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button id="ai-cfg-test" class="ai-act blue">Test</button>
<button id="ai-cfg-save" class="ai-act deploy">Save</button>
<button id="ai-cfg-clear" class="ai-act warn">Remove LLM</button>
<button id="ai-cfg-close" class="ai-act">Close</button>
</div>
</div>`;
document.body.appendChild(m);
const $ = (id) => document.getElementById(id);
const state = (t) => { $('ai-cfg-state').textContent = t; };
fetch('/api/v1/ai/settings').then((r) => r.json()).then((d) => {
if (d && d.success) {
$('ai-cfg-base').value = d.baseUrl || '';
$('ai-cfg-model').value = d.model || '';
state(d.configured ? `Active via ${d.source}. ${d.hasApiKey ? 'Key stored.' : 'No key.'}` : 'Not configured — pattern engine in use.');
if (d.source === 'env') $('ai-cfg-key').placeholder = 'env key in use — set to override';
}
}).catch(() => state('Could not load current settings.'));
$('ai-cfg-close').onclick = () => m.remove();
m.addEventListener('click', (e) => { if (e.target === m) m.remove(); });
$('ai-cfg-save').onclick = async () => {
state('Saving…');
try {
const r = await secureFetch('/api/v1/ai/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseUrl: $('ai-cfg-base').value, model: $('ai-cfg-model').value, apiKey: $('ai-cfg-key').value }),
});
const d = await r.json();
state(d.success ? 'Saved. The assistant now classifies with ' + (d.model || 'your model') + '.' : (d.error || 'Save failed.'));
if (d.success) { $('ai-cfg-key').value = ''; refreshEngineBadge(); }
} catch (e) { state('Save error: ' + e.message); }
};
$('ai-cfg-test').onclick = async () => {
state('Testing… (one classification round-trip)');
try {
const r = await secureFetch('/api/v1/ai/settings/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const d = await r.json();
if (d.success && d.ok) state(`✅ Works — sample: "${d.sample.intent}"${d.sample.reply ? ' — ' + d.sample.reply : ''}`);
else state('❌ ' + (d.reason || d.error || 'test failed'));
} catch (e) { state('Test error: ' + e.message); }
};
$('ai-cfg-clear').onclick = async () => {
state('Removing…');
try {
const r = await secureFetch('/api/v1/ai/settings', { method: 'DELETE' });
const d = await r.json();
state(d.success ? 'Removed — pattern engine active.' : (d.error || 'failed'));
refreshEngineBadge();
} catch (e) { state('Error: ' + e.message); }
};
}
document.addEventListener('click', (e) => {
if (e.target && e.target.id === 'ai-settings-btn') openSettings();
}, true);
window.aiChat = { send, deploy, toggle };
window.toggleAIChat = toggle;
})();
+22
View File
@@ -917,6 +917,28 @@
modal.classList.add('show');
});
// DC-163: programmatic deploy-config opener for the AI chat panel (and
// any other in-page consumer). Resolves the template id against the
// cached API templates (fetching them if needed), then opens the same
// deploy modal a manual click would. Returns false for unknown ids.
window.openDeployConfigFor = async function (templateId) {
if (!apiTemplates) {
const okFetch = await fetchAppTemplates();
if (!okFetch) return false;
}
const key = String(templateId || '').toLowerCase();
const entry = Object.entries(apiTemplates).find(([id]) => id.toLowerCase() === key);
if (!entry) return false;
const app = { id: entry[0], ...entry[1] };
if (app.isDashboardWidget) {
await buildAppSelector();
modal.classList.add('show');
return 'selector'; // widget — point at the selector grid instead
}
await showDeployConfig(app);
return true;
};
wireModal(modal, document.getElementById('app-selector-cancel'));
// Deploy modal event listeners
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-37f9769c30';
const CACHE = 'dashcaddy-shell-c62511c40d';
const PRECACHE = [
'/',
'/index.html',