feat: add 7 new features — exec shell, SSE events, compose import, docker resources, resource limits, email notifications, auto-updates

- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards)
- Live dashboard updates via SSE (resource alerts, health changes, update notices)
- Docker Compose import with YAML parsing, preview, and dependency-ordered deploy
- Volume & network management modal with disk usage overview
- CPU/memory resource limits on deploy and live update
- Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy
- Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly)

New deps: ws, js-yaml, nodemailer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SamiandClaude Opus 4.6 committed 2026-04-05 16:15:14 -07:00
1 parent b60e7e40d0
commit bdf3f247b1
30 files changed
+2423 -313

No files matched your search

+70 -55
View File
@@ -14,14 +14,17 @@
"express": "^4.22.1",
"express-rate-limit": "^7.5.1",
"helmet": "^8.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"lru-cache": "^10.4.3",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1",
"png-to-ico": "^2.1.8",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.3",
"sharp": "^0.33.5",
"validator": "^13.11.0"
"validator": "^13.11.0",
"ws": "^8.20.0"
},
"devDependencies": {
"eslint": "^8.57.1",
@@ -61,6 +64,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -605,26 +609,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@eslint/js": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
@@ -1100,6 +1084,30 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
@@ -1805,6 +1813,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1894,14 +1903,10 @@
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-flatten": {
"version": "1.1.1",
@@ -2188,6 +2193,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3007,6 +3013,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3087,13 +3094,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/eslint/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -3124,19 +3124,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/eslint/node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4795,14 +4782,12 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
@@ -5257,6 +5242,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -6918,6 +6912,27 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+4 -1
View File
@@ -19,14 +19,17 @@
"express": "^4.22.1",
"express-rate-limit": "^7.5.1",
"helmet": "^8.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"lru-cache": "^10.4.3",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1",
"png-to-ico": "^2.1.8",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.3",
"sharp": "^0.33.5",
"validator": "^13.11.0"
"validator": "^13.11.0",
"ws": "^8.20.0"
},
"devDependencies": {
"eslint": "^8.57.1",
+334
View File
@@ -0,0 +1,334 @@
const express = require('express');
const yaml = require('js-yaml');
const { DOCKER, REGEX } = require('../../constants');
const { ValidationError } = require('../../errors');
const platformPaths = require('../../platform-paths');
/**
* Docker Compose import routes
* Parse and deploy services from docker-compose.yml
* @param {Object} deps
*/
module.exports = function({ docker, caddy, servicesStateManager, portLockManager, asyncHandler, log, siteConfig, buildDomain, buildServiceUrl, addServiceToConfig, dns, notification }) {
const router = express.Router();
/**
* Parse a compose YAML string into DashCaddy-compatible service configs
*/
function parseCompose(yamlStr, stackName) {
let doc;
try {
doc = yaml.load(yamlStr);
} catch (e) {
throw new ValidationError(`Invalid YAML: ${e.message}`);
}
if (!doc || !doc.services || typeof doc.services !== 'object') {
throw new ValidationError('No services found in compose file');
}
const services = [];
const networks = Object.keys(doc.networks || {});
const volumes = Object.keys(doc.volumes || {});
for (const [name, svc] of Object.entries(doc.services)) {
if (!svc.image) {
// Build-based services can't be imported without the build context
services.push({ name, skip: true, reason: 'No image specified (build-only service)' });
continue;
}
const parsed = {
name,
image: svc.image,
ports: [],
volumes: [],
environment: {},
restart: svc.restart || 'unless-stopped',
networks: svc.networks || [],
dependsOn: svc.depends_on || [],
labels: { 'sami.managed': 'true', 'sami.compose-stack': stackName, 'sami.compose-service': name },
resources: {},
};
// Parse ports
if (svc.ports) {
for (const p of svc.ports) {
const str = String(p);
// Handle "8080:80", "8080:80/tcp", "127.0.0.1:8080:80"
const parts = str.split(':');
if (parts.length === 2) {
parsed.ports.push({ host: parts[0], container: parts[1].split('/')[0], protocol: parts[1].includes('/') ? parts[1].split('/')[1] : 'tcp' });
} else if (parts.length === 3) {
parsed.ports.push({ host: parts[1], container: parts[2].split('/')[0], protocol: parts[2].includes('/') ? parts[2].split('/')[1] : 'tcp' });
}
}
}
// Parse volumes
if (svc.volumes) {
for (const v of svc.volumes) {
if (typeof v === 'string') {
parsed.volumes.push(v);
} else if (v.source && v.target) {
const mode = v.read_only ? 'ro' : 'rw';
parsed.volumes.push(`${v.source}:${v.target}:${mode}`);
}
}
}
// Parse environment
if (svc.environment) {
if (Array.isArray(svc.environment)) {
for (const e of svc.environment) {
const [key, ...val] = String(e).split('=');
parsed.environment[key] = val.join('=');
}
} else {
parsed.environment = { ...svc.environment };
}
}
// Parse env_file entries (note: we record them but can't resolve file contents)
if (svc.env_file) {
parsed.envFileWarning = 'env_file references found — variables not imported (paste them as environment vars)';
}
// Resource limits
if (svc.deploy?.resources?.limits) {
const lim = svc.deploy.resources.limits;
if (lim.cpus) parsed.resources.cpus = parseFloat(lim.cpus);
if (lim.memory) {
const mem = String(lim.memory).toLowerCase();
if (mem.endsWith('g')) parsed.resources.memory = parseFloat(mem) * 1024;
else if (mem.endsWith('m')) parsed.resources.memory = parseFloat(mem);
else parsed.resources.memory = parseFloat(mem) / (1024 * 1024); // assume bytes
}
}
// Legacy mem_limit / cpus
if (svc.mem_limit) {
const mem = String(svc.mem_limit).toLowerCase();
if (mem.endsWith('g')) parsed.resources.memory = parseFloat(mem) * 1024;
else if (mem.endsWith('m')) parsed.resources.memory = parseFloat(mem);
}
if (svc.cpus) parsed.resources.cpus = parseFloat(svc.cpus);
// Cap-add
if (svc.cap_add) parsed.capAdd = svc.cap_add;
services.push(parsed);
}
return { services, networks, volumes, stackName };
}
/**
* Topological sort based on depends_on
*/
function topoSort(services) {
const graph = new Map();
const nameMap = new Map();
for (const svc of services) {
if (svc.skip) continue;
graph.set(svc.name, svc.dependsOn || []);
nameMap.set(svc.name, svc);
}
const sorted = [];
const visited = new Set();
const visiting = new Set();
function visit(name) {
if (visited.has(name)) return;
if (visiting.has(name)) return; // circular — just break
visiting.add(name);
for (const dep of (graph.get(name) || [])) {
if (graph.has(dep)) visit(dep);
}
visiting.delete(name);
visited.add(name);
if (nameMap.has(name)) sorted.push(nameMap.get(name));
}
for (const name of graph.keys()) visit(name);
return sorted;
}
// POST /import-compose — parse YAML and return preview
router.post('/import-compose', asyncHandler(async (req, res) => {
const { yaml: yamlStr, stackName } = req.body;
if (!yamlStr || typeof yamlStr !== 'string') {
throw new ValidationError('yaml field is required (string)');
}
const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack';
const result = parseCompose(yamlStr, name);
res.json({ success: true, ...result });
}, 'compose-import'));
// POST /deploy-compose — deploy parsed services
router.post('/deploy-compose', asyncHandler(async (req, res) => {
const { services, networks, stackName, subdomainPrefix } = req.body;
if (!services || !Array.isArray(services) || services.length === 0) {
throw new ValidationError('services array is required');
}
const prefix = (subdomainPrefix || stackName || 'stack').replace(/[^a-zA-Z0-9-]/g, '').substring(0, 16);
const results = [];
// Create networks first
if (networks && networks.length > 0) {
for (const net of networks) {
try {
await docker.client.createNetwork({ Name: `${prefix}_${net}`, Driver: 'bridge' });
results.push({ type: 'network', name: net, status: 'created' });
} catch (e) {
if (e.statusCode === 409) {
results.push({ type: 'network', name: net, status: 'exists' });
} else {
results.push({ type: 'network', name: net, status: 'failed', error: e.message });
}
}
}
}
// Sort by dependency order
const sorted = topoSort(services.filter(s => !s.skip));
for (const svc of sorted) {
const containerName = `${DOCKER.CONTAINER_PREFIX}${prefix}-${svc.name}`;
const subdomain = `${prefix}-${svc.name}`;
try {
// Pull image
try {
await docker.pull(svc.image);
} catch (pullErr) {
// Check if local
const images = await docker.client.listImages({ filters: { reference: [svc.image] } });
if (images.length === 0) throw new Error(`Image ${svc.image} not found: ${pullErr.message}`);
}
// Build container config
const containerConfig = {
Image: svc.image,
name: containerName,
ExposedPorts: {},
HostConfig: {
PortBindings: {},
Binds: (svc.volumes || []).map(v => {
const [hostPath, ...rest] = v.split(':');
const translated = platformPaths.toDockerMountPath(hostPath);
return rest.length > 0 ? `${translated}:${rest.join(':')}` : translated;
}),
RestartPolicy: { Name: svc.restart || 'unless-stopped' },
LogConfig: DOCKER.LOG_CONFIG,
},
Env: Object.entries(svc.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: svc.labels || {},
};
// Ports
if (svc.ports) {
for (const p of svc.ports) {
const key = `${p.container}/${p.protocol || 'tcp'}`;
containerConfig.ExposedPorts[key] = {};
containerConfig.HostConfig.PortBindings[key] = [{ HostPort: String(p.host) }];
}
}
// Resources
if (svc.resources?.memory) {
containerConfig.HostConfig.Memory = Math.round(svc.resources.memory * 1024 * 1024);
containerConfig.HostConfig.MemoryReservation = Math.round(svc.resources.memory * 1024 * 1024 * 0.5);
}
if (svc.resources?.cpus) {
containerConfig.HostConfig.NanoCpus = Math.round(svc.resources.cpus * 1e9);
}
// Capabilities
if (svc.capAdd) containerConfig.HostConfig.CapAdd = svc.capAdd;
// Networks
if (svc.networks && svc.networks.length > 0) {
containerConfig.HostConfig.NetworkMode = `${prefix}_${svc.networks[0]}`;
}
// Remove stale container with same name
try {
const existing = docker.client.getContainer(containerName);
await existing.remove({ force: true });
await new Promise(r => setTimeout(r, 1000));
} catch (_) {}
const container = await docker.client.createContainer(containerConfig);
await container.start();
// Determine port for Caddy/service registration
const mainPort = svc.ports?.[0]?.host || null;
// Add to services.json if it has a port (i.e., is web-accessible)
if (mainPort) {
const ip = siteConfig.dnsServerIp || 'localhost';
const serviceUrl = buildServiceUrl(subdomain);
await addServiceToConfig({
id: subdomain,
name: `${stackName || prefix}: ${svc.name}`,
logo: '/assets/docker.png',
url: serviceUrl,
containerId: container.id,
appTemplate: null,
routingMode: siteConfig.routingMode,
deployedAt: new Date().toISOString(),
deploymentManifest: {
templateId: null,
composeStack: stackName || prefix,
config: { subdomain, port: mainPort, ip }
}
});
}
results.push({ type: 'container', name: svc.name, containerId: container.id, status: 'deployed', subdomain: mainPort ? subdomain : null });
} catch (e) {
log.error('compose', `Failed to deploy service ${svc.name}`, { error: e.message });
results.push({ type: 'container', name: svc.name, status: 'failed', error: e.message });
}
}
// Skipped services
for (const svc of services.filter(s => s.skip)) {
results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason });
}
res.json({ success: true, results, stackName: stackName || prefix });
}, 'compose-deploy'));
// DELETE /compose-stack/:stackName — remove an entire stack
router.delete('/compose-stack/:stackName', asyncHandler(async (req, res) => {
const { stackName } = req.params;
if (!stackName) throw new ValidationError('stackName is required');
const containers = await docker.client.listContainers({ all: true, filters: { label: [`sami.compose-stack=${stackName}`] } });
const removed = [];
for (const c of containers) {
try {
const container = docker.client.getContainer(c.Id);
await container.remove({ force: true });
removed.push({ name: c.Names[0], id: c.Id });
} catch (e) {
removed.push({ name: c.Names[0], id: c.Id, error: e.message });
}
}
// Remove from services.json
const services = await servicesStateManager.read();
const updated = (services.services || []).filter(s => {
const manifest = s.deploymentManifest;
return !(manifest && manifest.composeStack === stackName);
});
await servicesStateManager.update(data => { data.services = updated; });
res.json({ success: true, removed, count: removed.length });
}, 'compose-stack-delete'));
return router;
};
+12
View File
@@ -170,6 +170,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
containerConfig.HostConfig.CapAdd = processedTemplate.docker.capabilities;
}
// Resource limits (CPU and memory)
if (userConfig.resources) {
if (userConfig.resources.memory) {
const memBytes = Math.round(userConfig.resources.memory * 1024 * 1024); // MB to bytes
containerConfig.HostConfig.Memory = memBytes;
containerConfig.HostConfig.MemoryReservation = Math.round(memBytes * 0.5); // soft limit = 50%
}
if (userConfig.resources.cpus) {
containerConfig.HostConfig.NanoCpus = Math.round(userConfig.resources.cpus * 1e9);
}
}
try {
log.info('docker', 'Pulling image', { image: processedTemplate.docker.image });
await docker.pull(processedTemplate.docker.image);
+2
View File
@@ -4,6 +4,7 @@ const initDeploy = require('./deploy');
const initRemoval = require('./removal');
const initTemplates = require('./templates');
const initRestore = require('./restore');
const initCompose = require('./compose');
/**
* Apps routes aggregator
@@ -44,6 +45,7 @@ module.exports = function(ctx) {
router.use(initRemoval(subCtx));
router.use(initTemplates(subCtx));
router.use(initRestore(subCtx));
router.use(initCompose(subCtx));
return router;
};
+30
View File
@@ -190,6 +190,36 @@ module.exports = function({ docker, log, asyncHandler }) {
success(res, { logs: logs.toString() });
}, 'container-logs'));
// Update resource limits on a running container
router.put('/:id/resources', asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
const { memory, cpus } = req.body;
const updateConfig = {};
if (memory !== undefined) {
updateConfig.Memory = memory > 0 ? Math.round(memory * 1024 * 1024) : 0; // MB to bytes, 0 = unlimited
updateConfig.MemoryReservation = memory > 0 ? Math.round(memory * 1024 * 1024 * 0.5) : 0;
}
if (cpus !== undefined) {
updateConfig.NanoCpus = cpus > 0 ? Math.round(cpus * 1e9) : 0; // 0 = unlimited
}
await container.update(updateConfig);
success(res, { message: 'Resource limits updated' });
}, 'container-resources'));
// Get resource limits for a container
router.get('/:id/resources', asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
const info = await container.inspect();
const hc = info.HostConfig;
success(res, {
memory: hc.Memory ? Math.round(hc.Memory / 1024 / 1024) : 0, // bytes to MB
memoryReservation: hc.MemoryReservation ? Math.round(hc.MemoryReservation / 1024 / 1024) : 0,
cpus: hc.NanoCpus ? hc.NanoCpus / 1e9 : 0,
});
}, 'container-resources-get'));
// Delete container
router.delete('/:id', asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
+113
View File
@@ -0,0 +1,113 @@
const express = require('express');
const { success } = require('../response-helpers');
const { ValidationError } = require('../errors');
/**
* Docker resources route factory (volumes, networks, disk usage)
* @param {Object} deps
* @param {Object} deps.docker - Docker client wrapper
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @returns {express.Router}
*/
module.exports = function({ docker, asyncHandler }) {
const router = express.Router();
// ===== VOLUMES =====
router.get('/volumes', asyncHandler(async (req, res) => {
const result = await docker.client.listVolumes();
const volumes = (result.Volumes || []).map(v => ({
name: v.Name,
driver: v.Driver,
mountpoint: v.Mountpoint,
scope: v.Scope,
created: v.CreatedAt,
labels: v.Labels || {},
}));
success(res, { volumes, count: volumes.length });
}, 'docker-volumes-list'));
router.post('/volumes', asyncHandler(async (req, res) => {
const { name, driver } = req.body;
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(name)) {
throw new ValidationError('Invalid volume name');
}
const volume = await docker.client.createVolume({
Name: name,
Driver: driver || 'local',
});
success(res, { message: `Volume "${name}" created`, volume: { name: volume.name } });
}, 'docker-volumes-create'));
router.delete('/volumes/:name', asyncHandler(async (req, res) => {
const volume = docker.client.getVolume(req.params.name);
await volume.remove({ force: req.query.force === 'true' });
success(res, { message: `Volume "${req.params.name}" removed` });
}, 'docker-volumes-delete'));
// ===== NETWORKS =====
router.get('/networks', asyncHandler(async (req, res) => {
const networkList = await docker.client.listNetworks();
const networks = networkList.map(n => ({
id: n.Id.substring(0, 12),
name: n.Name,
driver: n.Driver,
scope: n.Scope,
internal: n.Internal,
containers: Object.keys(n.Containers || {}).length,
created: n.Created,
}));
success(res, { networks, count: networks.length });
}, 'docker-networks-list'));
router.post('/networks', asyncHandler(async (req, res) => {
const { name, driver } = req.body;
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(name)) {
throw new ValidationError('Invalid network name');
}
const network = await docker.client.createNetwork({
Name: name,
Driver: driver || 'bridge',
});
success(res, { message: `Network "${name}" created`, id: network.id });
}, 'docker-networks-create'));
router.delete('/networks/:id', asyncHandler(async (req, res) => {
const network = docker.client.getNetwork(req.params.id);
await network.remove();
success(res, { message: 'Network removed' });
}, 'docker-networks-delete'));
// ===== DISK USAGE =====
router.get('/disk-usage', asyncHandler(async (req, res) => {
const df = await docker.client.df();
const summary = {
images: {
count: (df.Images || []).length,
size: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0),
reclaimable: (df.Images || []).filter(i => i.Containers === 0).reduce((sum, i) => sum + (i.Size || 0), 0),
},
containers: {
count: (df.Containers || []).length,
running: (df.Containers || []).filter(c => c.State === 'running').length,
size: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0),
},
volumes: {
count: (df.Volumes || []).length,
size: (df.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0),
reclaimable: (df.Volumes || []).filter(v => v.UsageData?.RefCount === 0).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0),
},
buildCache: {
count: (df.BuildCache || []).length,
size: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0),
reclaimable: (df.BuildCache || []).filter(b => !b.InUse).reduce((sum, b) => sum + (b.Size || 0), 0),
},
};
summary.totalSize = summary.images.size + summary.containers.size + summary.volumes.size + summary.buildCache.size;
success(res, summary);
}, 'docker-disk-usage'));
return router;
};
+111
View File
@@ -0,0 +1,111 @@
const express = require('express');
/**
* Server-Sent Events route factory
* Pushes real-time updates to connected dashboard clients
* @param {Object} deps - Dependencies
* @param {Object} deps.resourceMonitor - Container resource monitor
* @param {Object} deps.healthChecker - Health checker
* @param {Object} deps.updateManager - Update manager
* @param {Function} deps.logError - Error logging function
* @returns {express.Router}
*/
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError }) {
const router = express.Router();
const clients = new Set();
function broadcast(event, data) {
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of clients) {
try { res.write(msg); } catch (_) { clients.delete(res); }
}
}
// --- Wire up EventEmitter listeners ---
// Resource monitor events
if (resourceMonitor) {
resourceMonitor.on('alert', (data) => {
broadcast('resource-alert', data);
});
resourceMonitor.on('auto-restart', (data) => {
broadcast('auto-restart', data);
});
}
// Health checker events
if (healthChecker) {
healthChecker.on('status-check', (data) => {
broadcast('status-change', {
serviceId: data.serviceId,
name: data.name,
status: data.status,
responseTime: data.responseTime,
timestamp: data.timestamp
});
});
healthChecker.on('incident-created', (data) => {
broadcast('incident', { type: 'created', ...data });
});
healthChecker.on('incident-resolved', (data) => {
broadcast('incident', { type: 'resolved', ...data });
});
}
// Update manager events
if (updateManager) {
updateManager.on('update-available', (data) => {
broadcast('update-available', data);
});
updateManager.on('update-start', (data) => {
broadcast('update-start', data);
});
updateManager.on('update-complete', (data) => {
broadcast('update-complete', data);
});
updateManager.on('update-failed', (data) => {
broadcast('update-failed', data);
});
updateManager.on('auto-update-start', (data) => {
broadcast('auto-update-start', data);
});
updateManager.on('auto-update-complete', (data) => {
broadcast('auto-update-complete', data);
});
}
// SSE endpoint
router.get('/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Send initial connected event
res.write(`event: connected\ndata: ${JSON.stringify({ clients: clients.size + 1 })}\n\n`);
clients.add(res);
// Heartbeat every 30s
const heartbeat = setInterval(() => {
try { res.write(': heartbeat\n\n'); } catch (_) { cleanup(); }
}, 30000);
function cleanup() {
clearInterval(heartbeat);
clients.delete(res);
}
req.on('close', cleanup);
req.on('error', cleanup);
});
// Client count (useful for debugging)
router.get('/clients', (req, res) => {
res.json({ success: true, count: clients.size });
});
return router;
};
+124
View File
@@ -0,0 +1,124 @@
const { WebSocketServer } = require('ws');
const Docker = require('dockerode');
const url = require('url');
const docker = new Docker();
/**
* Attach WebSocket server for container exec/shell
* Route: ws://host/ws/exec/:containerId
* @param {http.Server} server - The HTTP server instance
* @param {Object} log - Logger
*/
module.exports = function attachExecWS(server, log) {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const parsed = url.parse(req.url, true);
const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
if (!match) return; // Not our route — let other handlers deal with it
const containerId = decodeURIComponent(match[1]);
wss.handleUpgrade(req, socket, head, (ws) => {
handleExec(ws, containerId, log);
});
});
return wss;
};
async function handleExec(ws, containerId, log) {
let execStream = null;
let execInstance = null;
try {
const container = docker.getContainer(containerId);
// Verify container exists and is running
const info = await container.inspect();
if (!info.State.Running) {
ws.send(JSON.stringify({ type: 'error', message: 'Container is not running' }));
ws.close();
return;
}
// Detect available shell
let shell = '/bin/sh';
try {
const bashCheck = await container.exec({ Cmd: ['which', 'bash'], AttachStdout: true });
const bashStream = await bashCheck.start();
const chunks = [];
await new Promise((resolve) => {
bashStream.on('data', (chunk) => chunks.push(chunk));
bashStream.on('end', resolve);
});
if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) {
shell = '/bin/bash';
}
} catch (_) {}
execInstance = await container.exec({
Cmd: [shell],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
});
execStream = await execInstance.start({ hijack: true, stdin: true, Tty: true });
ws.send(JSON.stringify({ type: 'connected', shell, containerId }));
// Docker → WebSocket
execStream.on('data', (chunk) => {
if (ws.readyState === ws.OPEN) {
ws.send(chunk);
}
});
execStream.on('end', () => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'exit' }));
ws.close();
}
});
// WebSocket → Docker
ws.on('message', (data) => {
if (!execStream.writable) return;
try {
// Check for control messages (JSON)
const str = data.toString();
if (str.startsWith('{"type":')) {
const msg = JSON.parse(str);
if (msg.type === 'resize' && execInstance && msg.cols && msg.rows) {
execInstance.resize({ h: msg.rows, w: msg.cols }).catch(() => {});
return;
}
}
} catch (_) {}
// Regular terminal input
execStream.write(data);
});
ws.on('close', () => {
if (execStream) {
try { execStream.destroy(); } catch (_) {}
}
});
ws.on('error', (err) => {
log.warn('exec', 'WebSocket error', { containerId, error: err.message });
if (execStream) {
try { execStream.destroy(); } catch (_) {}
}
});
} catch (err) {
log.error('exec', 'Failed to start exec session', { containerId, error: err.message });
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
ws.close();
}
}
}
+29
View File
@@ -1,5 +1,6 @@
const express = require('express');
const { validateURL, validateToken } = require('../input-validator');
const validatorLib = require('validator');
const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors');
@@ -32,6 +33,12 @@ module.exports = function({ notification, asyncHandler }) {
enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh'
},
email: {
enabled: notificationConfig.providers.email?.enabled || false,
configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to),
host: notificationConfig.providers.email?.host || '',
from: notificationConfig.providers.email?.from || ''
}
},
events: notificationConfig.events,
@@ -74,6 +81,19 @@ module.exports = function({ notification, asyncHandler }) {
throw new ValidationError('Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
}
}
if (providers.email?.to) {
const emails = providers.email.to.split(',').map(e => e.trim());
for (const email of emails) {
if (!validatorLib.isEmail(email)) {
throw new ValidationError(`Invalid email address: ${email}`);
}
}
}
if (providers.email?.host && typeof providers.email.host === 'string') {
if (!validatorLib.isFQDN(providers.email.host) && !validatorLib.isIP(providers.email.host)) {
throw new ValidationError('Invalid SMTP host');
}
}
}
// Update enabled state
@@ -101,6 +121,12 @@ module.exports = function({ notification, asyncHandler }) {
...providers.ntfy
};
}
if (providers.email) {
notificationConfig.providers.email = {
...notificationConfig.providers.email,
...providers.email
};
}
}
// Update events
@@ -144,6 +170,9 @@ module.exports = function({ notification, asyncHandler }) {
case 'ntfy':
result = await notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break;
case 'email':
result = await notification.sendEmail('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break;
default:
throw new ValidationError('Unknown provider');
}
+6
View File
@@ -59,6 +59,12 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
res.json({ success: true, message: 'Auto-update configured' });
}, 'updates-auto-update'));
// Get auto-update configuration
router.get('/updates/auto-update', asyncHandler(async (req, res) => {
const config = updateManager.getAutoUpdateConfig();
res.json({ success: true, config });
}, 'updates-auto-update-config'));
// Schedule update
router.post('/updates/schedule/:containerId', asyncHandler(async (req, res) => {
const { scheduledTime } = req.body;
+5
View File
@@ -52,6 +52,11 @@ process.on('uncaughtException', (error) => {
environment: process.env.NODE_ENV || 'production'
});
// Attach WebSocket exec handler
const attachExecWS = require('./routes/exec');
attachExecWS(server, log);
log.info('server', 'WebSocket exec handler attached');
// Start feature modules
const resourceMonitor = require('./resource-monitor');
const backupManager = require('./backup-manager');
+12
View File
@@ -66,6 +66,8 @@ const errorLogsRoutes = require('../routes/errorlogs');
const licenseRoutes = require('../routes/license');
const recipesRoutes = require('../routes/recipes');
const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events');
// Constants
const { APP } = require('../constants');
@@ -419,6 +421,16 @@ async function createApp() {
}));
apiRouter.use('/recipes', recipesRoutes(ctx));
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler }));
apiRouter.use('/docker', dockerResourcesRoutes({
docker: ctx.docker,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/events', eventsRoutes({
resourceMonitor: ctx.resourceMonitor,
healthChecker: ctx.healthChecker,
updateManager: ctx.updateManager,
logError: ctx.logError
}));
// Inline API routes
apiRouter.get('/health', (req, res) => {
+99 -6
View File
@@ -27,19 +27,22 @@ class UpdateManager extends EventEmitter {
}
/**
* Start update checking
* Start update checking and auto-update scheduler
*/
start() {
if (this.checking) return;
console.log('[UpdateManager] Starting update checks');
this.checking = true;
// Initial check
this.checkForUpdates();
// Schedule periodic checks
this.checkInterval = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL);
// Start auto-update scheduler (checks every hour)
this.startAutoUpdateScheduler();
}
/**
@@ -47,14 +50,18 @@ class UpdateManager extends EventEmitter {
*/
stop() {
if (!this.checking) return;
console.log('[UpdateManager] Stopping update checks');
this.checking = false;
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
if (this.autoUpdateInterval) {
clearInterval(this.autoUpdateInterval);
this.autoUpdateInterval = null;
}
}
/**
@@ -823,6 +830,92 @@ class UpdateManager extends EventEmitter {
return lines.join('\n') || 'No changelog available';
}
/**
* Start the auto-update scheduler — runs hourly, applies updates in maintenance windows
*/
startAutoUpdateScheduler() {
const AUTO_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
// Delay first run by 10 minutes to let containers start
setTimeout(() => this.runAutoUpdates(), 10 * 60 * 1000);
this.autoUpdateInterval = setInterval(() => this.runAutoUpdates(), AUTO_CHECK_INTERVAL);
const count = Object.values(this.config.autoUpdate || {}).filter(c => c.enabled).length;
if (count > 0) {
console.log(`[UpdateManager] Auto-update scheduler started (${count} container(s) configured)`);
}
}
/**
* Execute auto-updates for all configured containers
*/
async runAutoUpdates() {
const autoConfig = this.config.autoUpdate || {};
const now = new Date();
const hour = now.getHours();
const dayOfWeek = now.getDay(); // 0 = Sunday
const dayOfMonth = now.getDate();
for (const [containerId, cfg] of Object.entries(autoConfig)) {
if (!cfg.enabled) continue;
// Check maintenance window (e.g., "02:00-05:00")
if (cfg.maintenanceWindow) {
const [startStr, endStr] = cfg.maintenanceWindow.split('-').map(s => s.trim());
const startHour = parseInt(startStr);
const endHour = parseInt(endStr);
if (startHour <= endHour) {
if (hour < startHour || hour >= endHour) continue;
} else {
// Wraps midnight (e.g., "22:00-04:00")
if (hour < startHour && hour >= endHour) continue;
}
} else {
// Default: only run between 2AM and 4AM
if (hour < 2 || hour >= 4) continue;
}
// Check schedule
const shouldRun =
cfg.schedule === 'daily' ||
(cfg.schedule === 'weekly' && dayOfWeek === 0) || // Sunday
(cfg.schedule === 'monthly' && dayOfMonth === 1);
if (!shouldRun) continue;
// Check if already ran today
const lastRun = cfg.lastAutoUpdate ? new Date(cfg.lastAutoUpdate) : null;
if (lastRun && lastRun.toDateString() === now.toDateString()) continue;
// Check if this container has an available update
const update = this.availableUpdates.get(containerId);
if (!update) continue;
console.log(`[UpdateManager] Auto-updating ${update.containerName} (schedule: ${cfg.schedule})`);
this.emit('auto-update-start', { containerId, containerName: update.containerName, schedule: cfg.schedule });
try {
const result = await this.updateContainer(containerId, { autoRollback: cfg.autoRollback !== false });
cfg.lastAutoUpdate = now.toISOString();
this.saveConfig();
console.log(`[UpdateManager] Auto-update completed for ${update.containerName}`);
this.emit('auto-update-complete', { containerId, containerName: update.containerName, result });
} catch (error) {
console.error(`[UpdateManager] Auto-update failed for ${update.containerName}:`, error.message);
cfg.lastAutoUpdate = now.toISOString(); // Don't retry same day
this.saveConfig();
this.emit('auto-update-failed', { containerId, containerName: update.containerName, error: error.message });
}
}
}
/**
* Get auto-update configuration for all containers
*/
getAutoUpdateConfig() {
return this.config.autoUpdate || {};
}
/**
* Configure auto-update for a container
*/
+4
View File
@@ -22,6 +22,7 @@ const bundles = {
JS('core', 'service-infrastructure.js'),
JS('core', 'service-crud.js'),
JS('core', 'service-create.js'),
JS('live-events.js'),
],
'features.js': [
JS('logo-customization.js'),
@@ -37,6 +38,9 @@ const bundles = {
JS('resource-monitor.js'),
JS('health-check.js'),
JS('update-management.js'),
JS('docker-resources.js'),
JS('compose-import.js'),
JS('container-exec.js'),
JS('audit-log.js'),
JS('weather.js'),
JS('clock.js'),
+16
View File
@@ -1964,6 +1964,22 @@ button:focus-visible {
cursor: wait;
}
/* Exec/terminal button styling — subtle, hover-only */
.exec-btn {
margin-left: 4px !important;
font-size: .7rem !important;
font-family: monospace !important;
padding: .2rem .45rem !important;
opacity: 0;
transition: opacity 0.2s ease;
}
.card:hover .exec-btn {
opacity: 0.6;
}
.exec-btn:hover {
opacity: 1 !important;
}
/* Credentials (key) button styling */
.creds-btn {
margin-right: 8px !important;
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+97 -97
View File
@@ -1,35 +1,35 @@
const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const v=await fetch("/api/v1/config");if(v.ok){const y=await v.json();if(y.tld&&(SITE.tld=y.tld.startsWith(".")?y.tld:"."+y.tld),y.dns&&(SITE.dnsIp=y.dns.ip||"",SITE.dnsPort=y.dns.port||DC.DEFAULTS.DNS_PORT),y.dnsServers&&typeof y.dnsServers=="object")for(const[h,r]of Object.entries(y.dnsServers))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(SITE.dnsServers[h]=r);y.configurationType&&(SITE.configurationType=y.configurationType),y.domain&&(SITE.domain=y.domain),y.defaults&&(SITE.defaults=y.defaults),y.routingMode&&(SITE.routingMode=y.routingMode),SITE.onboardingCompleted=y.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const k=document.getElementById("manage-tokens");k&&(k.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(v=>v.textContent=SITE.tld);const i=document.getElementById("edit-tld-suffix");i&&(i.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const i=SITE.dnsServers[o];return i?`${i.ip}:${i.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,i]of Object.entries(SITE.dnsServers))if(i.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const i=Object.keys(SITE.dnsServers);if(!i.length)return;const g='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',v=o.firstElementChild;i.forEach(y=>{const k=escapeHtml(y),h=escapeHtml((SITE.dnsServers[y].name||y).toUpperCase()),r=document.createElement("div");r.className="card",r.setAttribute("data-app",y),r.setAttribute("data-status","off"),r.innerHTML=`<span id="${k}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${g}</div><span class="name">${h}</span><span class="spacer"></span><span id="${k}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${k}-time" class="response-time">--</span></div><div class="health-row" id="health-${k}"><span id="uptime-${k}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${k}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${k}-restart" class="restart-btn">Restart</button><button id="${k}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${k}-open">Open</button><button id="${k}-logs" class="logs-btn">Logs</button><button id="${k}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(r,v)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.json()).token,csrfToken}catch(o){throw console.error("Failed to get CSRF token:",o),o}}async function secureFetch(o,i={}){const g=(i.method||"GET").toUpperCase(),v=!["GET","HEAD","OPTIONS"].includes(g);if(v)try{const k=await getCSRFToken();i.headers={...i.headers,"X-CSRF-Token":k}}catch(k){console.error("Failed to add CSRF token to request:",k)}i.signal||(i={...i,signal:AbortSignal.timeout(15e3)});const y=await fetch(o,i);if(v&&y.status===403)try{const k=await y.clone().json();if(k.error&&(k.error.includes("DC-100")||k.error.includes("DC-101"))){csrfToken=nLine truncated
const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const f=await fetch("/api/v1/config");if(f.ok){const m=await f.json();if(m.tld&&(SITE.tld=m.tld.startsWith(".")?m.tld:"."+m.tld),m.dns&&(SITE.dnsIp=m.dns.ip||"",SITE.dnsPort=m.dns.port||DC.DEFAULTS.DNS_PORT),m.dnsServers&&typeof m.dnsServers=="object")for(const[b,a]of Object.entries(m.dnsServers))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(SITE.dnsServers[b]=a);m.configurationType&&(SITE.configurationType=m.configurationType),m.domain&&(SITE.domain=m.domain),m.defaults&&(SITE.defaults=m.defaults),m.routingMode&&(SITE.routingMode=m.routingMode),SITE.onboardingCompleted=m.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const l=document.getElementById("manage-tokens");l&&(l.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(f=>f.textContent=SITE.tld);const i=document.getElementById("edit-tld-suffix");i&&(i.textContent=SITE.tld);const v=document.getElementById("external-proxy-ip");v&&SITE.dnsIp&&(v.value=SITE.dnsIp,v.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const i=SITE.dnsServers[o];return i?`${i.ip}:${i.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,i]of Object.entries(SITE.dnsServers))if(i.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const i=Object.keys(SITE.dnsServers);if(!i.length)return;const v='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',f=o.firstElementChild;i.forEach(m=>{const l=escapeHtml(m),b=escapeHtml((SITE.dnsServers[m].name||m).toUpperCase()),a=document.createElement("div");a.className="card",a.setAttribute("data-app",m),a.setAttribute("data-status","off"),a.innerHTML=`<span id="${l}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${v}</div><span class="name">${b}</span><span class="spacer"></span><span id="${l}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${l}-time" class="response-time">--</span></div><div class="health-row" id="health-${l}"><span id="uptime-${l}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${l}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${l}-restart" class="restart-btn">Restart</button><button id="${l}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${l}-open">Open</button><button id="${l}-logs" class="logs-btn">Logs</button><button id="${l}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(a,f)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.json()).token,csrfToken}catch(o){throw console.error("Failed to get CSRF token:",o),o}}async function secureFetch(o,i={}){const v=(i.method||"GET").toUpperCase(),f=!["GET","HEAD","OPTIONS"].includes(v);if(f)try{const l=await getCSRFToken();i.headers={...i.headers,"X-CSRF-Token":l}}catch(l){console.error("Failed to add CSRF token to request:",l)}i.signal||(i={...i,signal:AbortSignal.timeout(15e3)});const m=await fetch(o,i);if(f&&m.status===403)try{const l=await m.clone().json();if(l.error&&(l.error.includes("DC-100")||l.error.includes("DC-101"))){csrfToken=nLine truncated
position: fixed; top: 20px; right: 20px;
background: ${k.bg}; color: ${k.fg};
background: ${l.bg}; color: ${l.fg};
padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px;
`,document.body.appendChild(h),g>0&&setTimeout(()=>h.remove(),g)}function timeAgo(o){const i=Date.now()-new Date(o).getTime();return i<6e4?"just now":i<36e5?Math.floor(i/6e4)+"m ago":i<864e5?Math.floor(i/36e5)+"h ago":Math.floor(i/864e5)+"d ago"}function safeGet(o,i=null){try{const g=localStorage.getItem(o);return g!==null?g:i}catch{return i}}function safeSet(o,i){try{localStorage.setItem(o,i)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,i=null){try{const g=sessionStorage.getItem(o);return g!==null?g:i}catch{return i}}function safeSessionSet(o,i){try{sessionStorage.setItem(o,i)}catch{}}function safeGetJSON(o,i=null){try{const g=localStorage.getItem(o);return g?JSON.parse(g):i}catch{return i}}function escapeHtml(o){return String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(o,i){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",i)}const DC_BUS={_handlers:{},on(o,i){var g;((g=this._handlers)[o]||(g[o]=[])).push(i)},off(o,i){this._handlers[o]=this._handlers[o]?.filter(g=>g!==i)},emit(o,i){this._handlers[o]?.forEach(g=>g(i))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(i=>i.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const i=this._apps.findIndex(g=>g.id===o);return i>-1&&(this._apps.splice(i,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),i>-1},updateApp(o,i){const g=this._apps.find(v=>v.id===o);if(g){for(const[v,y]of Object.entries(i))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(g[v]=y);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function o(){const v=document.createElement("div");return v.className="skeleton-card",v.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',v}function i(v){const y=document.getElementById("cards");if(!(!y||y.querySelector(".card"))){v=v||6;for(let k=0;k<v;k++){const h=o();y.appendChild(h),setTimeout(function(){h.classList.add("loaded")},k*60)}}}function g(){const v=document.getElementById("cards");if(v){var y=v.querySelectorAll(".skeleton-card");y.length&&(y.forEach(function(k,h){setTimeout(function(){k.style.opacity="0",k.style.transform="translateY(-10px)"},h*25)}),setTimeout(function(){y.forEach(function(k){k.parentNode&&k.remove()})},y.length*25+300))}}window.SkeletonLoader={show:i,hide:g}})(),(function(){var o="theme",i="user-themes",g="custom-theme",v=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],y=v.slice(),k=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],h=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],r=["fg-muted","hover","card-hover","base","success","error","warning"],E={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
`,document.body.appendChild(b),v>0&&setTimeout(()=>b.remove(),v)}function timeAgo(o){const i=Date.now()-new Date(o).getTime();return i<6e4?"just now":i<36e5?Math.floor(i/6e4)+"m ago":i<864e5?Math.floor(i/36e5)+"h ago":Math.floor(i/864e5)+"d ago"}function safeGet(o,i=null){try{const v=localStorage.getItem(o);return v!==null?v:i}catch{return i}}function safeSet(o,i){try{localStorage.setItem(o,i)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,i=null){try{const v=sessionStorage.getItem(o);return v!==null?v:i}catch{return i}}function safeSessionSet(o,i){try{sessionStorage.setItem(o,i)}catch{}}function safeGetJSON(o,i=null){try{const v=localStorage.getItem(o);return v?JSON.parse(v):i}catch{return i}}function escapeHtml(o){return String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(o,i){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",i)}const DC_BUS={_handlers:{},on(o,i){var v;((v=this._handlers)[o]||(v[o]=[])).push(i)},off(o,i){this._handlers[o]=this._handlers[o]?.filter(v=>v!==i)},emit(o,i){this._handlers[o]?.forEach(v=>v(i))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(i=>i.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const i=this._apps.findIndex(v=>v.id===o);return i>-1&&(this._apps.splice(i,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),i>-1},updateApp(o,i){const v=this._apps.find(f=>f.id===o);if(v){for(const[f,m]of Object.entries(i))f!=="__proto__"&&f!=="constructor"&&f!=="prototype"&&(v[f]=m);DC_BUS.emit("apps:changed",this._apps)}return v}};(function(){function o(){const f=document.createElement("div");return f.className="skeleton-card",f.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',f}function i(f){const m=document.getElementById("cards");if(!(!m||m.querySelector(".card"))){f=f||6;for(let l=0;l<f;l++){const b=o();m.appendChild(b),setTimeout(function(){b.classList.add("loaded")},l*60)}}}function v(){const f=document.getElementById("cards");if(f){var m=f.querySelectorAll(".skeleton-card");m.length&&(m.forEach(function(l,b){setTimeout(function(){l.style.opacity="0",l.style.transform="translateY(-10px)"},b*25)}),setTimeout(function(){m.forEach(function(l){l.parentNode&&l.remove()})},m.length*25+300))}}window.SkeletonLoader={show:i,hide:v}})(),(function(){var o="theme",i="user-themes",v="custom-theme",f=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],m=f.slice(),l=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],b=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],a=["fg-muted","hover","card-hover","base","success","error","warning"],k={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+m.r+","+m.g+","+m.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+m.r+","+m.g+","+m.b+`, .05), transparent 55%),
radial-gradient(1200px 800px at 10% -10%, rgba(`+g.r+","+g.g+","+g.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+g.r+","+g.g+","+g.b+`, .05), transparent 55%),
var(--bg);
}
`:":root."+w+` body {
`:":root."+x+` body {
background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+m.r+","+m.g+","+m.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+m.r+","+m.g+","+m.b+`, .07), transparent 55%),
radial-gradient(1200px 900px at 8% -12%, rgba(`+g.r+","+g.g+","+g.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+g.r+","+g.g+","+g.b+`, .07), transparent 55%),
var(--bg);
}
`}function u(w,T){var $=T.lightBg||T.bg&&f(T.bg)>.4;return $?":root."+w+` button:hover {
`}function p(x,T){var $=T.lightBg||T.bg&&h(T.bg)>.4;return $?":root."+x+` button:hover {
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
border-color: rgba(0, 0, 0, .15);
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
}
`:":root."+w+` button:hover {
`:":root."+x+` button:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function l(){k.forEach(function(w){document.documentElement.style.removeProperty("--"+w)})}function p(w,T){var $=w.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),v.indexOf($)!==-1&&($=$+"-custom");for(var b=safeGetJSON(i,{}),m=$,S=2;b[$]&&$!==T;)$=m+"-"+S++;return $}function s(w){var T=document.getElementById("user-theme-styles");T&&T.remove(),y.length=v.length,Object.keys(E).forEach(function(x){v.indexOf(x)===-1&&delete E[x]});var $=w||safeGetJSON(i,{}),b=Object.keys($);if(b=b.filter(function(x){return v.indexOf(x)===-1}),!!b.length){var m="";b.forEach(function(x){var C=$[x];y.indexOf(x)===-1&&y.push(x);var P={};k.forEach(function(D){C[D]&&(P[D]=C[D])}),P["card-bg"]=C["card-base"]||C.bg,C.lightBg&&(P.lightBg=!0);var O=e(P);r.forEach(function(D){!P[D]&&O[D]&&(P[D]=O[D])}),E[x]=P,m+=":root."+x+` {
`,k.forEach(function(D){P[D]&&(m+=" --"+D+": "+P[D]+`;
`)}),m+=`}
`,m+=a(x,P),m+=u(x,P)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=m,document.head.appendChild(S)}}function I(){secureFetch("/api/v1/themes").then(function(w){return w.json()}).then(function(w){if(!(!w.success||!w.themes)){var T=w.themes,$=safeGetJSON(i,{});if(JSON.stringify(T)!==JSON.stringify($)){safeSet(i,JSON.stringify(T)),s(T);var b=safeGet(o);b&&y.indexOf(b)!==-1&&L(b)}}}).catch(function(){})}function B(){var w=safeGetJSON(g);if(w){var T=w.name||"Custom",$=p(T),b={name:T};k.forEach(function(x){w[x]&&(b[x]=w[x])});var m=safeGetJSON(i,{});m[$]=b,safeSet(i,JSON.stringify(m)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(g);var S={};k.forEach(function(x){b[x]&&(S[x]=b[x])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,colors:S})}).catch(function(){})}}function L(w){document.documentElement.classList.add("theme-transitioning"),y.forEach(function(m){m!=="dark"&&document.documentElement.classList.remove(m)}),l(),w!=="dark"&&document.documentElement.classList.add(w),safeSet(o,w);var T=E[w],$=document.querySelector('meta[name="theme-color"]');$&&T&&$.setAttribute("content",T.bg);var b=T&&T.lightBg;!b&&T&&T.bg&&(b=f(T.bg)>.4),b?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),s();var A=safeGet(o);A==="red"&&(A="black",safeSet(o,"black")),A&&A!=="dark"&&y.indexOf(A)===-1&&(A=null),L(A||n()),I(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(w){safeGet(o)||L(w.matches?"dark":"light")}),window.THEMES=y,window.BUILTIN_THEMES=v,window.THEME_COLORS=E,window.THEME_PROPS=k,window.BASE_PROPS=h,window.DERIVED_PROPS=r,window.USER_THEMES_KEY=i,window.applyTheme=L,window.clearCustomProperties=l,window.injectUserThemeStyles=s,window.syncThemesFromServer=I,window.slugifyThemeName=p,window.getActiveTheme=function(){return safeGet(o)||n()},window.deriveExtendedColors=e,window.hexToRgb=t,window.rgbToHex=d,window.blendColors=c})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const E=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!E)return;const t=(.299*+E[0]+.587*+E[1]+.114*+E[2])/255,d=h.querySelector(".totp-logo-dark"),c=h.querySelector(".totp-logo-light");d&&(d.style.display=t>.5?"none":""),c&&(c.style.display=t>.5?"":"none")}function i(){const h=document.getElementById("totp-overlay");if(h){h.classList.add("show"),setTimeout(o,50);const r=h.querySelector(".totp-digits input");r&&setTimeout(()=>r.focus(),100)}}function g(){const h=document.getElementById("totp-overlay");h&&h.classList.remove("show")}const v=document.getElementById("totp-digits");if(v){const h=v.querySelectorAll("input");h.forEach((r,E)=>{r.addEventListener("input",t=>{const d=t.target.value.replace(/\D/g,"");t.target.value=d.slice(0,1),d&&E<h.length-1&&h[E+1].focus();const c=Array.from(h).map(f=>f.value).join("");c.length===6&&y(c)}),r.addEventListener("keydown",t=>{t.key==="Backspace"&&!t.target.value&&E>0&&(h[E-1].focus(),h[E-1].value="")}),r.addEventListener("paste",t=>{t.preventDefault();const d=(t.clipboardData.getData("text")||"").replace(/\D/g,"");d.length>=6&&(h.forEach((c,f)=>{c.value=d[f]||""}),h[5].focus(),y(d.slice(0,6)))})})}async function y(h){const r=document.getElementById("totp-error");r.textContent="Verifying...",r.className="totp-error verifying";try{const t=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();if(t.success){r.textContent="",t.csrfToken&&(csrfToken=t.csrfToken),g();const d=safeSessionGet("totp_redirect");if(d){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=d;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{r.textContent=t.error||"Invalid code",r.className="totp-error";const d=document.querySelectorAll("#totp-digits input");d.forEach(c=>{c.value=""}),d[0]?.focus()}}catch{r.textContent="Connection error",r.className="totp-error"}}const k=new URLSearchParams(window.location.search);if(k.get("auth")==="required"){const h=k.get("return");if(h)try{const r=new URL(h,window.location.origin),E=r.hostname,t=r.origin===window.location.origin,d=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,c=E.endsWith(d)||E===d.substring(1);(t||c)&&safeSessionSet("totp_redirect",h)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=i})(),(function(){injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal">
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function c(){l.forEach(function(x){document.documentElement.style.removeProperty("--"+x)})}function y(x,T){var $=x.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),f.indexOf($)!==-1&&($=$+"-custom");for(var w=safeGetJSON(i,{}),g=$,C=2;w[$]&&$!==T;)$=g+"-"+C++;return $}function r(x){var T=document.getElementById("user-theme-styles");T&&T.remove(),m.length=f.length,Object.keys(k).forEach(function(E){f.indexOf(E)===-1&&delete k[E]});var $=x||safeGetJSON(i,{}),w=Object.keys($);if(w=w.filter(function(E){return f.indexOf(E)===-1}),!!w.length){var g="";w.forEach(function(E){var S=$[E];m.indexOf(E)===-1&&m.push(E);var P={};l.forEach(function(O){S[O]&&(P[O]=S[O])}),P["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(P.lightBg=!0);var N=e(P);a.forEach(function(O){!P[O]&&N[O]&&(P[O]=N[O])}),k[E]=P,g+=":root."+E+` {
`,l.forEach(function(O){P[O]&&(g+=" --"+O+": "+P[O]+`;
`)}),g+=`}
`,g+=s(E,P),g+=p(E,P)});var C=document.createElement("style");C.id="user-theme-styles",C.textContent=g,document.head.appendChild(C)}}function I(){secureFetch("/api/v1/themes").then(function(x){return x.json()}).then(function(x){if(!(!x.success||!x.themes)){var T=x.themes,$=safeGetJSON(i,{});if(JSON.stringify(T)!==JSON.stringify($)){safeSet(i,JSON.stringify(T)),r(T);var w=safeGet(o);w&&m.indexOf(w)!==-1&&L(w)}}}).catch(function(){})}function B(){var x=safeGetJSON(v);if(x){var T=x.name||"Custom",$=y(T),w={name:T};l.forEach(function(E){x[E]&&(w[E]=x[E])});var g=safeGetJSON(i,{});g[$]=w,safeSet(i,JSON.stringify(g)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(v);var C={};l.forEach(function(E){w[E]&&(C[E]=w[E])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,colors:C})}).catch(function(){})}}function L(x){document.documentElement.classList.add("theme-transitioning"),m.forEach(function(g){g!=="dark"&&document.documentElement.classList.remove(g)}),c(),x!=="dark"&&document.documentElement.classList.add(x),safeSet(o,x);var T=k[x],$=document.querySelector('meta[name="theme-color"]');$&&T&&$.setAttribute("content",T.bg);var w=T&&T.lightBg;!w&&T&&T.bg&&(w=h(T.bg)>.4),w?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),r();var A=safeGet(o);A==="red"&&(A="black",safeSet(o,"black")),A&&A!=="dark"&&m.indexOf(A)===-1&&(A=null),L(A||n()),I(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(x){safeGet(o)||L(x.matches?"dark":"light")}),window.THEMES=m,window.BUILTIN_THEMES=f,window.THEME_COLORS=k,window.THEME_PROPS=l,window.BASE_PROPS=b,window.DERIVED_PROPS=a,window.USER_THEMES_KEY=i,window.applyTheme=L,window.clearCustomProperties=c,window.injectUserThemeStyles=r,window.syncThemesFromServer=I,window.slugifyThemeName=y,window.getActiveTheme=function(){return safeGet(o)||n()},window.deriveExtendedColors=e,window.hexToRgb=t,window.rgbToHex=d,window.blendColors=u})(),(function(){function o(){const b=document.querySelector(".totp-card");if(!b)return;const k=getComputedStyle(b).backgroundColor.match(/\d+/g);if(!k)return;const t=(.299*+k[0]+.587*+k[1]+.114*+k[2])/255,d=b.querySelector(".totp-logo-dark"),u=b.querySelector(".totp-logo-light");d&&(d.style.display=t>.5?"none":""),u&&(u.style.display=t>.5?"":"none")}function i(){const b=document.getElementById("totp-overlay");if(b){b.classList.add("show"),setTimeout(o,50);const a=b.querySelector(".totp-digits input");a&&setTimeout(()=>a.focus(),100)}}function v(){const b=document.getElementById("totp-overlay");b&&b.classList.remove("show")}const f=document.getElementById("totp-digits");if(f){const b=f.querySelectorAll("input");b.forEach((a,k)=>{a.addEventListener("input",t=>{const d=t.target.value.replace(/\D/g,"");t.target.value=d.slice(0,1),d&&k<b.length-1&&b[k+1].focus();const u=Array.from(b).map(h=>h.value).join("");u.length===6&&m(u)}),a.addEventListener("keydown",t=>{t.key==="Backspace"&&!t.target.value&&k>0&&(b[k-1].focus(),b[k-1].value="")}),a.addEventListener("paste",t=>{t.preventDefault();const d=(t.clipboardData.getData("text")||"").replace(/\D/g,"");d.length>=6&&(b.forEach((u,h)=>{u.value=d[h]||""}),b[5].focus(),m(d.slice(0,6)))})})}async function m(b){const a=document.getElementById("totp-error");a.textContent="Verifying...",a.className="totp-error verifying";try{const t=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();if(t.success){a.textContent="",t.csrfToken&&(csrfToken=t.csrfToken),v();const d=safeSessionGet("totp_redirect");if(d){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=d;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{a.textContent=t.error||"Invalid code",a.className="totp-error";const d=document.querySelectorAll("#totp-digits input");d.forEach(u=>{u.value=""}),d[0]?.focus()}}catch{a.textContent="Connection error",a.className="totp-error"}}const l=new URLSearchParams(window.location.search);if(l.get("auth")==="required"){const b=l.get("return");if(b)try{const a=new URL(b,window.location.origin),k=a.hostname,t=a.origin===window.location.origin,d=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,u=k.endsWith(d)||k===d.substring(1);(t||u)&&safeSessionSet("totp_redirect",b)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=i})(),(function(){injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3>
@@ -127,7 +127,7 @@ const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HE
</button>
</div>
</div>
</div>`);const o=document.getElementById("service-creds-modal");let i=null;const g=["sonarr","radarr","prowlarr","overseerr"],v=["sonarr","radarr"];function y(t){return t.externalUrl||t.url||""}function k(t){const d=document.getElementById("svc-creds-error");d.textContent=t,d.style.display=""}function h(){const t=document.getElementById("svc-creds-error");t.textContent="",t.style.display="none"}window.openServiceCredsModal=async function(t){i=t,h();const d=document.getElementById("svc-creds-title"),c=document.getElementById("svc-creds-desc"),f=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),a=document.getElementById("svc-creds-basic"),u=document.getElementById("svc-creds-quality");d.textContent=t.name+" Credentials";const n=!!t.isExternal,l=g.includes(t.id)||g.includes(t.appTemplate),p=v.includes(t.id)||v.includes(t.appTemplate);f.style.display=n?"":"none",e.style.display=l?"":"none",u.style.display=p?"":"none",a.style.display=n?"none":"";const s=document.getElementById("svc-quality-select");s.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",n?(c.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${t.name}`):l?c.textContent="API key bypasses the app login screen automatically.":c.textContent="Credentials are injected automatically when accessing this service.",await r(t),o.classList.add("show")};async function r(t){const d=document.getElementById("svc-creds-dot"),c=document.getElementById("svc-creds-status"),f=document.getElementById("svc-creds-clear");let e=!1;if(t.isExternal){try{const n=await(await fetch(`/api/v1/seedhost-creds?serviceId=${t.id}`)).json();n.success?(document.getElementById("svc-seedhost-user").value=n.username||"",n.hasCredentials&&(e=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const n=await(await fetch(`/api/v1/services/${t.id}/credentials`)).json();n.success&&(n.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",e=!0):document.getElementById("svc-apikey-input").value="",n.hasBasicAuth&&!t.isExternal?(document.getElementById("svc-basic-user").value=n.username||"",e=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const a=t.id||t.appTemplate;if(v.includes(a)&&await E(t),e){d.style.background="var(--ok-fg, #74dfc4)",c.style.color="var(--ok-fg, #74dfc4)",c.textContent="Credentials stored",f.style.display="";const u=document.getElementById(`creds-btn-${t.id}`);u&&u.classList.add("has-creds")}else d.style.background="var(--muted)",c.style.color="var(--muted)",c.textContent="No credentials stored",f.style.display="none"}async function E(t){const d=document.getElementById("svc-quality-select"),c=document.getElementById("svc-quality-status"),f=t.id||t.appTemplate,e=y(t);if(!e){d.innerHTML='<option value="">-- No service URL --</option>';return}d.innerHTML='<option value="">Loading...</option>',c.textContent="";try{const a=new URLSearchParams({service:f,url:e}),n=await(await fetch(`/api/v1/arr/quality-profiles?${a}`)).json();if(!n.success||!n.profiles?.length){d.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}d.innerHTML="";for(const l of n.profiles){const p=document.createElement("option");p.value=l.id,p.textContent=l.name,d.appendChild(p)}if(n.storedProfileId&&(d.value=String(n.storedProfileId)),!d.value){const l=n.profiles.find(p=>/720/i.test(p.name));l&&(d.value=String(l.id))}!d.value&&n.profiles.length&&(d.value=String(n.profiles[0].id)),c.innerHTML=`<span style="color: var(--ok-fg);">${n.profiles.length} profiles loaded</span>`}catch(a){d.innerHTML='<option value="">-- Failed to load --</option>',c.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${a.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!i)return;const t=i.id||i.appTemplate,d=y(i),f=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),a=document.getElementById("svc-quality-status");if(!d){a.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!f||f==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){a.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',a.textContent="";try{const u=new URLSearchParams({service:t,url:d,apiKey:f}),l=await(await fetch(`/api/v1/arr/quality-profiles?${u}`)).json();if(!l.success){e.innerHTML='<option value="">-- Error --</option>',a.innerHTML=`<span style="color: var(--error, #c62828);">${l.error||"Failed to feLine truncated
</div>`);const o=document.getElementById("service-creds-modal");let i=null;const v=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function m(t){return t.externalUrl||t.url||""}function l(t){const d=document.getElementById("svc-creds-error");d.textContent=t,d.style.display=""}function b(){const t=document.getElementById("svc-creds-error");t.textContent="",t.style.display="none"}window.openServiceCredsModal=async function(t){i=t,b();const d=document.getElementById("svc-creds-title"),u=document.getElementById("svc-creds-desc"),h=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),s=document.getElementById("svc-creds-basic"),p=document.getElementById("svc-creds-quality");d.textContent=t.name+" Credentials";const n=!!t.isExternal,c=v.includes(t.id)||v.includes(t.appTemplate),y=f.includes(t.id)||f.includes(t.appTemplate);h.style.display=n?"":"none",e.style.display=c?"":"none",p.style.display=y?"":"none",s.style.display=n?"none":"";const r=document.getElementById("svc-quality-select");r.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",n?(u.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${t.name}`):c?u.textContent="API key bypasses the app login screen automatically.":u.textContent="Credentials are injected automatically when accessing this service.",await a(t),o.classList.add("show")};async function a(t){const d=document.getElementById("svc-creds-dot"),u=document.getElementById("svc-creds-status"),h=document.getElementById("svc-creds-clear");let e=!1;if(t.isExternal){try{const n=await(await fetch(`/api/v1/seedhost-creds?serviceId=${t.id}`)).json();n.success?(document.getElementById("svc-seedhost-user").value=n.username||"",n.hasCredentials&&(e=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const n=await(await fetch(`/api/v1/services/${t.id}/credentials`)).json();n.success&&(n.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",e=!0):document.getElementById("svc-apikey-input").value="",n.hasBasicAuth&&!t.isExternal?(document.getElementById("svc-basic-user").value=n.username||"",e=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const s=t.id||t.appTemplate;if(f.includes(s)&&await k(t),e){d.style.background="var(--ok-fg, #74dfc4)",u.style.color="var(--ok-fg, #74dfc4)",u.textContent="Credentials stored",h.style.display="";const p=document.getElementById(`creds-btn-${t.id}`);p&&p.classList.add("has-creds")}else d.style.background="var(--muted)",u.style.color="var(--muted)",u.textContent="No credentials stored",h.style.display="none"}async function k(t){const d=document.getElementById("svc-quality-select"),u=document.getElementById("svc-quality-status"),h=t.id||t.appTemplate,e=m(t);if(!e){d.innerHTML='<option value="">-- No service URL --</option>';return}d.innerHTML='<option value="">Loading...</option>',u.textContent="";try{const s=new URLSearchParams({service:h,url:e}),n=await(await fetch(`/api/v1/arr/quality-profiles?${s}`)).json();if(!n.success||!n.profiles?.length){d.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}d.innerHTML="";for(const c of n.profiles){const y=document.createElement("option");y.value=c.id,y.textContent=c.name,d.appendChild(y)}if(n.storedProfileId&&(d.value=String(n.storedProfileId)),!d.value){const c=n.profiles.find(y=>/720/i.test(y.name));c&&(d.value=String(c.id))}!d.value&&n.profiles.length&&(d.value=String(n.profiles[0].id)),u.innerHTML=`<span style="color: var(--ok-fg);">${n.profiles.length} profiles loaded</span>`}catch(s){d.innerHTML='<option value="">-- Failed to load --</option>',u.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${s.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!i)return;const t=i.id||i.appTemplate,d=m(i),h=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status");if(!d){s.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!h||h==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){s.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',s.textContent="";try{const p=new URLSearchParams({service:t,url:d,apiKey:h}),c=await(await fetch(`/api/v1/arr/quality-profiles?${p}`)).json();if(!c.success){e.innerHTML='<option value="">-- Error --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">${c.error||"Failed to feLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -222,7 +222,7 @@ const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HE
</button>
</div>
</div>
</div>`);async function o(){try{const y=await(await fetch("/api/v1/totp/config")).json();if(!y.success)return;const{enabled:k,sessionDuration:h,isSetUp:r}=y.config,E=document.getElementById("totp-status-dot"),t=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),c=document.getElementById("totp-setup-section"),f=document.getElementById("totp-qr-section"),e=document.getElementById("totp-duration-section"),a=document.getElementById("totp-disable-section");k&&r?(E.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",t.textContent="TOTP is active",t.style.color="var(--ok-fg, #7ef2ff)",c.style.display="none",f.style.display="none",e.style.display="block",a.style.display="block",document.getElementById("totp-duration-select").value=h):(E.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",t.textContent="TOTP is not configured",t.style.color="var(--muted)",c.style.display="block",f.style.display="none",e.style.display="none",a.style.display="none"),g(k&&r,h)}catch(v){console.warn("Failed to load TOTP settings:",v)}}const i={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function g(v,y){const k=document.getElementById("auth-card"),h=document.getElementById("auth-pill"),r=document.getElementById("auth-dot"),E=document.getElementById("auth-status-text");k&&(v?(k.setAttribute("data-status","on"),h.className="badge on",h.textContent="YES",r.className="dot ok at-bl",E.textContent="Session: "+(i[y]||y)):(k.setAttribute("data-status","off"),h.className="badge off",h.textContent="NO",r.className="dot bad at-bl",E.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const y=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();y.success&&(document.getElementById("totp-qr-image").src=y.qrCode,document.getElementById("totp-manual-key").textContent=y.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(v){console.error("TOTP setup failed:",v)}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const v=document.getElementById("totp-import-key").value.trim(),y=document.getElementById("totp-import-error");if(y.textContent="",!v){y.textContent="Paste a Base32 secret key first";return}try{const h=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:v})})).json();h.success?(y.textContent="",document.getElementById("totp-qr-image").src=h.qrCode,document.getElementById("totp-manual-key").textContent=h.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):y.textContent=h.error||h.message||"Import failed"}catch{y.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const v=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(v).then(()=>{const y=document.getElementById("totp-copy-key");y.textContent="\u2705",setTimeout(()=>{y.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const v=document.getElementById("totp-setup-code").value,y=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(v)){y.textContent="Enter a 6-digit code";return}try{const h=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:v})})).json();h.success?(y.textContent="",o()):y.textContent=h.error||"Invalid code"}catch{y.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",v=>{v.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async v=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:v.target.value})}),o()}catch(y){console.error("Failed to update session duration:",y)}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POLine truncated
</div>`);async function o(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:l,sessionDuration:b,isSetUp:a}=m.config,k=document.getElementById("totp-status-dot"),t=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),u=document.getElementById("totp-setup-section"),h=document.getElementById("totp-qr-section"),e=document.getElementById("totp-duration-section"),s=document.getElementById("totp-disable-section");l&&a?(k.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",t.textContent="TOTP is active",t.style.color="var(--ok-fg, #7ef2ff)",u.style.display="none",h.style.display="none",e.style.display="block",s.style.display="block",document.getElementById("totp-duration-select").value=b):(k.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",t.textContent="TOTP is not configured",t.style.color="var(--muted)",u.style.display="block",h.style.display="none",e.style.display="none",s.style.display="none"),v(l&&a,b)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const i={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function v(f,m){const l=document.getElementById("auth-card"),b=document.getElementById("auth-pill"),a=document.getElementById("auth-dot"),k=document.getElementById("auth-status-text");l&&(f?(l.setAttribute("data-status","on"),b.className="badge on",b.textContent="YES",a.className="dot ok at-bl",k.textContent="Session: "+(i[m]||m)):(l.setAttribute("data-status","off"),b.className="badge off",b.textContent="NO",a.className="dot bad at-bl",k.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){console.error("TOTP setup failed:",f)}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!f){m.textContent="Paste a Base32 secret key first";return}try{const b=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();b.success?(m.textContent="",document.getElementById("totp-qr-image").src=b.qrCode,document.getElementById("totp-manual-key").textContent=b.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):m.textContent=b.error||b.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=document.getElementById("totp-setup-code").value,m=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(f)){m.textContent="Enter a 6-digit code";return}try{const b=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();b.success?(m.textContent="",o()):m.textContent=b.error||"Invalid code"}catch{m.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",f=>{f.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async f=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:f.target.value})}),o()}catch(m){console.error("Failed to update session duration:",m)}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POLine truncated
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3>
@@ -240,40 +240,40 @@ const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HE
</div>
</div>
</div>
`);function o(){return Object.keys(SITE.dnsServers||{})}function i(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function g(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const l=o();if(l.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const p of l)n.insertAdjacentHTML("beforeend",`
`);function o(){return Object.keys(SITE.dnsServers||{})}function i(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function v(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const c=o();if(c.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const y of c)n.insertAdjacentHTML("beforeend",`
<div class="token-section">
<h4 class="token-section-title">${i(p)}</h4>
<h4 class="token-section-title">${i(y)}</h4>
<div class="token-grid">
<div class="token-field">
<label for="${p}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${p}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<label for="${y}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${y}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${p}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${p}-readonly-token">\u{1F441}</button>
<input type="password" id="${y}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${y}-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="${p}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${p}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<label for="${y}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${y}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${p}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${p}-admin-token">\u{1F441}</button>
<input type="password" id="${y}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${y}-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="${p}-token-status"></div>
<div class="token-status" id="${y}-token-status"></div>
</div>
`)}function v(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const l=safeGet("dashcaddy-encryption-key");if(l)return safeSessionSet("dashcaddy-encryption-key",l),safeRemove("dashcaddy-encryption-key"),l;const p=new Uint8Array(32);return crypto.getRandomValues(p),n=Array.from(p,s=>s.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const y=v();function k(n,l){if(!n)return"";const p=crypto.getRandomValues(new Uint8Array(8)),s=Array.from(p,L=>L.toString(16).padStart(2,"0")).join(""),I=new TextEncoder().encode(l+s);let B="";for(let L=0;L<n.length;L++){const A=n.charCodeAt(L)^I[L%I.length]^p[L%p.length]^L*31+17&255;B+=String.fromCharCode(A)}return s+":"+btoa(B)}function h(n,l){if(!n)return"";try{if(n.indexOf(":")===16){const B=n.substring(0,16),L=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(n.substring(17)),w=new TextEncoder().encode(l+B);let T="";for(let $=0;$<A.length;$++){const b=A.charCodeAt($)^w[$%w.length]^L[$%L.length]^$*31+17&255;T+=String.fromCharCode(b)}return T}const s=atob(n);let I="";for(let B=0;B<s.length;B++){const L=s.charCodeAt(B)^l.charCodeAt(B%l.length);I+=String.fromCharCode(L)}return I}catch{return""}}function r(n,l,p){const s=safeGet(`${n}-${l}-${p}-enc`);return h(s,y)}function E(n,l,p,s){const I=`${n}-${l}-${p}-enc`;s?safeSet(I,k(s,y)):safeRemove(I)}function t(n,l){return r(n,l,"token")}function d(n,l){return r(n,l,"username")}function c(n,l,p){E(n,l,"token",p)}function f(n,l,p){E(n,l,"username",p)}function e(){const n={};for(const l of o())n[l]={readonly:{username:d(l,"readonly"),token:t(l,"readonly")},admin:{username:d(l,"admin"),token:t(l,"admin")}};return n}function a(){o().forEach(n=>{["readonly","admin"].forEach(l=>{["token","username"].forEach(p=>{safeRemove(`${n}-${l}-${p}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function u(n){const l=t(n,"readonly"),p=d(n,"readonly"),s=t(n,"admin"),I=d(n,"admin"),B=h(safeGet(`${n}-token-enc`),y),L=h(safeGet(`${n}-username-enc`),y);return{username:I||p||L,token:s||l||B,readonlyToken:l||B,readonlyUsername:p||L,adminToken:s||B,adminUsername:I||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const n=document.getElementById("token-management-modal"),l=e();o().forEach(p=>{const s=l[p];document.getElementById(`${p}-readonly-username`).value=s.readonly.username,document.getElementById(`${p}-readonly-token`).value=s.readonly.token,document.getElementById(`${p}-admin-username`).value=s.admin.username,document.getElementById(`${p}-admin-token`).value=s.admin.token,document.getElementById(`${p}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const l=n.target.closest(".token-toggle");if(l){const p=l.dataset.target,s=document.getElementById(p);s.type==="password"?(s.type="text",l.textContent="\u{1F648}"):(s.type="password",l.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=o();n.forEach(s=>{f(s,"readonly",document.getElementById(`${s}-readonly-username`).value.trim()),c(s,"readonly",document.getElementById(`${s}-readonly-token`).value.trim()),f(s,"admin",document.getElementById(`${s}-admin-username`).value.trim()),c(s,"admin",document.getElementById(`${s}-admin-token`).value.trim())});const l={};let p=!1;if(n.forEach(s=>{const I={},B=document.getElementById(`${s}-readonly-username`).value.trim(),L=document.getElementById(`${s}-readonly-token`).value.trim(),A=document.getElementById(`${s}-admin-username`).value.trim(),w=document.getElementById(`${s}-admin-token`).value.trim();B&&L&&(I.readonly={username:B,password:L},p=!0),A&&w&&(I.admin={username:A,password:w},p=!0),Object.keys(I).length>0&&(l[s]=I)}),p){n.forEach(s=>{l[s]&&(document.getElementById(`${s}-token-status`).textContent="Verifying...",document.getElementById(`${s}-token-status`).className="token-status")});try{const I=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:l})})).json();I.results?n.forEach(B=>{const L=document.getElementById(`${B}-token-status`);if(!l[B]){L.textContent="";return}const A=I.results[B];A?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):A?.partial?(L.textContent="\u2713 "+A.partial,L.className="token-status success"):(L.textContent="\u2717 "+(A?.error||"Login failed"),L.className="token-status error")}):I.success?n.forEach(B=>{l[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):n.forEach(B=>{l[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(I.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(s){console.erro Line truncated
`)}function f(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const c=safeGet("dashcaddy-encryption-key");if(c)return safeSessionSet("dashcaddy-encryption-key",c),safeRemove("dashcaddy-encryption-key"),c;const y=new Uint8Array(32);return crypto.getRandomValues(y),n=Array.from(y,r=>r.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const m=f();function l(n,c){if(!n)return"";const y=crypto.getRandomValues(new Uint8Array(8)),r=Array.from(y,L=>L.toString(16).padStart(2,"0")).join(""),I=new TextEncoder().encode(c+r);let B="";for(let L=0;L<n.length;L++){const A=n.charCodeAt(L)^I[L%I.length]^y[L%y.length]^L*31+17&255;B+=String.fromCharCode(A)}return r+":"+btoa(B)}function b(n,c){if(!n)return"";try{if(n.indexOf(":")===16){const B=n.substring(0,16),L=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(n.substring(17)),x=new TextEncoder().encode(c+B);let T="";for(let $=0;$<A.length;$++){const w=A.charCodeAt($)^x[$%x.length]^L[$%L.length]^$*31+17&255;T+=String.fromCharCode(w)}return T}const r=atob(n);let I="";for(let B=0;B<r.length;B++){const L=r.charCodeAt(B)^c.charCodeAt(B%c.length);I+=String.fromCharCode(L)}return I}catch{return""}}function a(n,c,y){const r=safeGet(`${n}-${c}-${y}-enc`);return b(r,m)}function k(n,c,y,r){const I=`${n}-${c}-${y}-enc`;r?safeSet(I,l(r,m)):safeRemove(I)}function t(n,c){return a(n,c,"token")}function d(n,c){return a(n,c,"username")}function u(n,c,y){k(n,c,"token",y)}function h(n,c,y){k(n,c,"username",y)}function e(){const n={};for(const c of o())n[c]={readonly:{username:d(c,"readonly"),token:t(c,"readonly")},admin:{username:d(c,"admin"),token:t(c,"admin")}};return n}function s(){o().forEach(n=>{["readonly","admin"].forEach(c=>{["token","username"].forEach(y=>{safeRemove(`${n}-${c}-${y}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function p(n){const c=t(n,"readonly"),y=d(n,"readonly"),r=t(n,"admin"),I=d(n,"admin"),B=b(safeGet(`${n}-token-enc`),m),L=b(safeGet(`${n}-username-enc`),m);return{username:I||y||L,token:r||c||B,readonlyToken:c||B,readonlyUsername:y||L,adminToken:r||B,adminUsername:I||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{v();const n=document.getElementById("token-management-modal"),c=e();o().forEach(y=>{const r=c[y];document.getElementById(`${y}-readonly-username`).value=r.readonly.username,document.getElementById(`${y}-readonly-token`).value=r.readonly.token,document.getElementById(`${y}-admin-username`).value=r.admin.username,document.getElementById(`${y}-admin-token`).value=r.admin.token,document.getElementById(`${y}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const c=n.target.closest(".token-toggle");if(c){const y=c.dataset.target,r=document.getElementById(y);r.type==="password"?(r.type="text",c.textContent="\u{1F648}"):(r.type="password",c.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=o();n.forEach(r=>{h(r,"readonly",document.getElementById(`${r}-readonly-username`).value.trim()),u(r,"readonly",document.getElementById(`${r}-readonly-token`).value.trim()),h(r,"admin",document.getElementById(`${r}-admin-username`).value.trim()),u(r,"admin",document.getElementById(`${r}-admin-token`).value.trim())});const c={};let y=!1;if(n.forEach(r=>{const I={},B=document.getElementById(`${r}-readonly-username`).value.trim(),L=document.getElementById(`${r}-readonly-token`).value.trim(),A=document.getElementById(`${r}-admin-username`).value.trim(),x=document.getElementById(`${r}-admin-token`).value.trim();B&&L&&(I.readonly={username:B,password:L},y=!0),A&&x&&(I.admin={username:A,password:x},y=!0),Object.keys(I).length>0&&(c[r]=I)}),y){n.forEach(r=>{c[r]&&(document.getElementById(`${r}-token-status`).textContent="Verifying...",document.getElementById(`${r}-token-status`).className="token-status")});try{const I=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:c})})).json();I.results?n.forEach(B=>{const L=document.getElementById(`${B}-token-status`);if(!c[B]){L.textContent="";return}const A=I.results[B];A?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):A?.partial?(L.textContent="\u2713 "+A.partial,L.className="token-status success"):(L.textContent="\u2717 "+(A?.error||"Login failed"),L.className="token-status error")}):I.success?n.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):n.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(I.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(r){console.erro Line truncated
Current: ${f.currentVersion}
New: ${f.updateVersion}
Current: ${h.currentVersion}
New: ${h.updateVersion}
`+(f.updateTitle?`${f.updateTitle}
`+(h.updateTitle?`${h.updateTitle}
`:"")+`The DNS server will restart during the update.
Proceed?`)){t.textContent=d,t.disabled=!1,t.title="Update DNS server";return}t.textContent="\u{1F504}",t.title="Updating...";const u=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(E)}`,{method:"POST"})).json();if(!u.success)throw new Error(u.error||"Update failed");if(u.manualUpdateRequired){t.textContent="\u2B06\uFE0F",t.title=`Update available: ${u.newVersion}`;const n=u.downloadLink?`
Download: ${u.downloadLink}`:"",l=u.instructionsLink?`
Instructions: ${u.instructionsLink}`:"";showNotification(`${r.toUpperCase()} update requires manual installation. Current: ${u.previousVersion} \u2192 ${u.newVersion}. Please update manually on the host machine.`,"warning",8e3),t.disabled=!1;return}t.textContent="\u2705",t.title="Updated successfully!",showNotification(`${r.toUpperCase()} updated successfully! ${u.previousVersion} \u2192 ${u.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{t.textContent=d,t.disabled=!1,t.title="Update DNS server",window.refreshAll()},1e4)}catch(c){console.error("DNS update error:",c),t.textContent="\u274C",t.title="Update failed",showNotification(`Failed to update ${r.toUpperCase()}: ${c.message}`,"error"),setTimeout(()=>{t.textContent=d,t.disabled=!1,t.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",r=>{const E=r.target.closest('[id$="-update"]');if(!E)return;const t=E.id.replace("-update","");SITE.dnsServers[t]&&i(t,SITE.dnsServers[t]?.ip)}),injectModal("dns-settings-modal",`
Proceed?`)){t.textContent=d,t.disabled=!1,t.title="Update DNS server";return}t.textContent="\u{1F504}",t.title="Updating...";const p=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(k)}`,{method:"POST"})).json();if(!p.success)throw new Error(p.error||"Update failed");if(p.manualUpdateRequired){t.textContent="\u2B06\uFE0F",t.title=`Update available: ${p.newVersion}`;const n=p.downloadLink?`
Download: ${p.downloadLink}`:"",c=p.instructionsLink?`
Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} update requires manual installation. Current: ${p.previousVersion} \u2192 ${p.newVersion}. Please update manually on the host machine.`,"warning",8e3),t.disabled=!1;return}t.textContent="\u2705",t.title="Updated successfully!",showNotification(`${a.toUpperCase()} updated successfully! ${p.previousVersion} \u2192 ${p.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{t.textContent=d,t.disabled=!1,t.title="Update DNS server",window.refreshAll()},1e4)}catch(u){console.error("DNS update error:",u),t.textContent="\u274C",t.title="Update failed",showNotification(`Failed to update ${a.toUpperCase()}: ${u.message}`,"error"),setTimeout(()=>{t.textContent=d,t.disabled=!1,t.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",a=>{const k=a.target.closest('[id$="-update"]');if(!k)return;const t=k.id.replace("-update","");SITE.dnsServers[t]&&i(t,SITE.dnsServers[t]?.ip)}),injectModal("dns-settings-modal",`
<div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3>
@@ -300,7 +300,7 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);let g=null;function v(r){g=r;const E=SITE.dnsServers[r]||{},t=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(E.name||r).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=E.ip||"",document.getElementById("dns-edit-port").value=E.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=E.name||"",t.classList.add("show")}async function y(){if(!g)return;const r=document.getElementById("dns-edit-ip").value.trim(),E=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,t=document.getElementById("dns-edit-name").value.trim();if(!r){showNotification("Server IP is required","warning");return}const d={dnsServers:{}};d.dnsServers[g]={ip:r,port:String(E)},t&&(d.dnsServers[g].name=t);try{const f=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)})).json();f.success?(SITE.dnsServers[g]=d.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),h(),window.refreshAll()):showNotification(f.error||"Failed to save settings","error")}catch(c){showNotification("Failed to save: "+c.message,"error")}}async function k(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const E=await(await secureFetch("/api/v1/config")).json();E.dnsServers&&delete E.dnsServers[g];const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:E.dnsServers||{}})})).json();if(d.success){delete SITE.dnsServers[g];const c=document.querySelector(`.top [data-app="${g}"]`);c&&c.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),h()}else showNotification(d.error||"Failed to remove","error")}catch(r){showNotification("Failed to remove: "+r.message,"error")}}function h(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",h),document.getElementById("dns-settings-save")?.addEventListener("click",y),document.getElementById("dns-settings-delete")?.addEventListener("click",k),document.getElementById("dns-settings-modal")?.addEventListener("click",r=>{r.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",r=>{const E=r.target.closest('[id$="-settings"]');if(!E)return;const t=E.id.replace("-settings","");SITE.dnsServers[t]&&(r.stopPropagation(),v(t))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
</div>`);let v=null;function f(a){v=a;const k=SITE.dnsServers[a]||{},t=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(k.name||a).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=k.ip||"",document.getElementById("dns-edit-port").value=k.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=k.name||"",t.classList.add("show")}async function m(){if(!v)return;const a=document.getElementById("dns-edit-ip").value.trim(),k=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,t=document.getElementById("dns-edit-name").value.trim();if(!a){showNotification("Server IP is required","warning");return}const d={dnsServers:{}};d.dnsServers[v]={ip:a,port:String(k)},t&&(d.dnsServers[v].name=t);try{const h=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)})).json();h.success?(SITE.dnsServers[v]=d.dnsServers[v],showNotification(`${v.toUpperCase()} settings saved`,"success"),b(),window.refreshAll()):showNotification(h.error||"Failed to save settings","error")}catch(u){showNotification("Failed to save: "+u.message,"error")}}async function l(){if(v&&confirm(`Remove ${v.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const k=await(await secureFetch("/api/v1/config")).json();k.dnsServers&&delete k.dnsServers[v];const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:k.dnsServers||{}})})).json();if(d.success){delete SITE.dnsServers[v];const u=document.querySelector(`.top [data-app="${v}"]`);u&&u.remove(),showNotification(`${v.toUpperCase()} removed from dashboard`,"success"),b()}else showNotification(d.error||"Failed to remove","error")}catch(a){showNotification("Failed to remove: "+a.message,"error")}}function b(){closeModal("dns-settings-modal"),v=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",b),document.getElementById("dns-settings-save")?.addEventListener("click",m),document.getElementById("dns-settings-delete")?.addEventListener("click",l),document.getElementById("dns-settings-modal")?.addEventListener("click",a=>{a.target.id==="dns-settings-modal"&&b()}),document.querySelector(".top")?.addEventListener("click",a=>{const k=a.target.closest('[id$="-settings"]');if(!k)return;const t=k.id.replace("-settings","");SITE.dnsServers[t]&&(a.stopPropagation(),f(t))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
<div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header">
@@ -324,66 +324,66 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
</div>
</div>
</div>
</div>`);let o=null,i=null,g=!1,v=null,y=null,k=!1,h=null,r=null,E=!1,t=null,d=!1;async function c(b,m=25){try{const S=getDnsServerAddr(b),x=await fetch(`/api/v1/dns/logs?server=${S}&limit=${m}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.json();return C.success&&C.logs?{logs:C.logs,count:C.count,server:C.server}:{error:C.error||"Failed to fetch logs"}}else return x.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${x.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function f(b){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[b]||"var(--fg)"}function e(b){const m=document.createElement("div");if(m.className="log-entry",m.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",b.parsed===!1)return m.style.gridTemplateColumns="1fr",m.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(b.raw)}</span>`,m;const S=f(b.rcode),x=b.rcode==="Refused"||b.rcode==="REFUSED";return m.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(b.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(b.client)}">${escapeHtml(b.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${x?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(b.domain)}">${escapeHtml(b.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(b.type)}</span>
<span style="color: ${S}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(b.rcode)}</span>
`,m}async function a(){if(E){await T();return}if(k){await B();return}if(g||!o)return;const b=parseInt(document.getElementById("log-lines").value),m=document.getElementById("logs-content");try{const S=await c(o,b);if(S.error){m.innerHTML=`
</div>`);let o=null,i=null,v=!1,f=null,m=null,l=!1,b=null,a=null,k=!1,t=null,d=!1;async function u(w,g=25){try{const C=getDnsServerAddr(w),E=await fetch(`/api/v1/dns/logs?server=${C}&limit=${g}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return E.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${E.status}`}}catch(C){return console.error("DNS logs fetch failed:",C),{error:C.message}}}function h(w){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[w]||"var(--fg)"}function e(w){const g=document.createElement("div");if(g.className="log-entry",g.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",w.parsed===!1)return g.style.gridTemplateColumns="1fr",g.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(w.raw)}</span>`,g;const C=h(w.rcode),E=w.rcode==="Refused"||w.rcode==="REFUSED";return g.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(w.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(w.client)}">${escapeHtml(w.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${E?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(w.domain)}">${escapeHtml(w.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(w.type)}</span>
<span style="color: ${C}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(w.rcode)}</span>
`,g}async function s(){if(k){await T();return}if(l){await B();return}if(v||!o)return;const w=parseInt(document.getElementById("log-lines").value),g=document.getElementById("logs-content");try{const C=await u(o,w);if(C.error){g.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div>
</div>`;return}m.innerHTML=`
<div>${escapeHtml(C.error)}</div>
</div>`;return}g.innerHTML=`
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span>Time</span>
<span>Client</span>
<span>Domain</span>
<span>Type</span>
<span>Status</span>
</div>`,S.logs&&S.logs.length>0?S.logs.forEach(x=>{const C=e(x);m.appendChild(C)}):m.innerHTML+=`
</div>`,C.logs&&C.logs.length>0?C.logs.forEach(E=>{const S=e(E);g.appendChild(S)}):g.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet
</div>`}catch(S){m.innerHTML=`
</div>`}catch(C){g.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function u(b){o=b,g=!1,k=!1;const m=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),x=document.getElementById("logs-pause"),C=document.getElementById("logs-stream");S.textContent=`${b.toUpperCase()} DNS Logs`,x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),C&&(C.style.display="none"),m.classList.add("show"),a(),i=setInterval(a,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),i&&(clearInterval(i),i=null),p(),o=null,k=!1,v=null,y=null,E=!1,h=null,r=null,g=!1}function l(b){t&&p();const m=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),x=document.getElementById("logs-content");i&&(clearInterval(i),i=null);try{t=new EventSource(`/api/v1/logs/stream/${b}`),d=!0,m.classList.add("active"),m.textContent="\u{1F534} Live",m.title="Streaming - click to stop",S.style.display="none";const C=document.getElementById("logs-title");C.textContent.includes("\u{1F534}")||(C.innerHTML=C.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),t.onmessage=P=>{try{const O=JSON.parse(P.data);if(O.error){console.error("Stream error:",O.error),p();return}const D=document.createElement("div");D.className="log-entry",D.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(O.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${U}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(D.innerHTML=`
Failed to fetch logs: ${escapeHtml(C.message)}
</div>`}}function p(w){o=w,v=!1,l=!1;const g=document.getElementById("logs-modal"),C=document.getElementById("logs-title"),E=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");C.textContent=`${w.toUpperCase()} DNS Logs`,E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),S&&(S.style.display="none"),g.classList.add("show"),s(),i=setInterval(s,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),i&&(clearInterval(i),i=null),y(),o=null,l=!1,f=null,m=null,k=!1,b=null,a=null,v=!1}function c(w){t&&y();const g=document.getElementById("logs-stream"),C=document.getElementById("logs-pause"),E=document.getElementById("logs-content");i&&(clearInterval(i),i=null);try{t=new EventSource(`/api/v1/logs/stream/${w}`),d=!0,g.classList.add("active"),g.textContent="\u{1F534} Live",g.title="Streaming - click to stop",C.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),t.onmessage=P=>{try{const N=JSON.parse(P.data);if(N.error){console.error("Stream error:",N.error),y();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(N.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${U}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
<div style="flex-shrink: 0;">${M}</div>
<div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(O.text)}</div>
`,x.appendChild(D),x.scrollTop=x.scrollHeight;x.children.length>500;)x.removeChild(x.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},t.onerror=P=>{console.error("EventSource error:",P),p()}}catch(C){console.error("Failed to start streaming:",C),p()}}function p(){t&&(t.close(),t=null),d=!1;const b=document.getElementById("logs-stream"),m=document.getElementById("logs-pause"),S=document.getElementById("logs-title");b&&(b.classList.remove("active"),b.textContent="\u{1F4E1} Live",b.title="Enable real-time streaming"),m&&(m.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),k&&v&&!i&&(i=setInterval(B,DC.POLL.LOGS))}async function s(b,m=100){try{const S=`/api/v1/logs/container/${b}?tail=${m}&timestamps=true`,x=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.json();return C.success&&C.logs?{logs:C.logs,count:C.count,containerName:C.containerName,containerId:C.containerId}:{error:C.error||"Failed to fetch container logs"}}else return{error:`HTTP ${x.status}: ${x.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function I(b){const m=document.createElement("div");m.className="log-entry",m.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=b.stream==="stderr"?"var(--bad-fg)":"var(--fg)",x=b.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return m.innerHTML=`
<div style="flex-shrink: 0;">${x}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(b.text)}</div>
`,m}async function B(){if(g||!v||!k)return;const b=parseInt(document.getElementById("log-lines").value),m=document.getElementById("logs-content");try{const S=await s(v,b);if(S.error){m.innerHTML=`
<div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(N.text)}</div>
`,E.appendChild(O),E.scrollTop=E.scrollHeight;E.children.length>500;)E.removeChild(E.firstChild)}catch(N){console.error("Error parsing stream data:",N)}},t.onerror=P=>{console.error("EventSource error:",P),y()}}catch(S){console.error("Failed to start streaming:",S),y()}}function y(){t&&(t.close(),t=null),d=!1;const w=document.getElementById("logs-stream"),g=document.getElementById("logs-pause"),C=document.getElementById("logs-title");w&&(w.classList.remove("active"),w.textContent="\u{1F4E1} Live",w.title="Enable real-time streaming"),g&&(g.style.display=""),C&&(C.textContent=C.textContent.replace(" \u{1F534}","")),l&&f&&!i&&(i=setInterval(B,DC.POLL.LOGS))}async function r(w,g=100){try{const C=`/api/v1/logs/container/${w}?tail=${g}&timestamps=true`,E=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${E.status}: ${E.statusText}`}}catch(C){return console.error("Container logs fetch failed:",C),{error:C.message}}}function I(w){const g=document.createElement("div");g.className="log-entry",g.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=w.stream==="stderr"?"var(--bad-fg)":"var(--fg)",E=w.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return g.innerHTML=`
<div style="flex-shrink: 0;">${E}</div>
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(w.text)}</div>
`,g}async function B(){if(v||!f||!l)return;const w=parseInt(document.getElementById("log-lines").value),g=document.getElementById("logs-content");try{const C=await r(f,w);if(C.error){g.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div>
</div>`;return}m.innerHTML=`
<div>${escapeHtml(C.error)}</div>
</div>`;return}g.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex-shrink: 0; width: 80px;">Stream</span>
<span style="flex: 1;">Log Output</span>
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(x=>{const C=I(x);m.appendChild(C)}),m.scrollTop=m.scrollHeight):m.innerHTML+=`
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(E=>{const S=I(E);g.appendChild(S)}),g.scrollTop=g.scrollHeight):g.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container
</div>`}catch(S){m.innerHTML=`
</div>`}catch(C){g.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function L(b,m){v=b,y=m,k=!0,E=!1,g=!1,p();const S=document.getElementById("logs-modal"),x=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");x.textContent=`\u{1F4CB} ${m} - Container Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display=""),S.classList.add("show"),B(),i=setInterval(B,DC.POLL.LOGS)}async function A(b,m=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(b)}&tail=${m}`,x=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.json();return C.success&&C.logs?{logs:C.logs,count:C.count,logPath:C.logPath,totalLines:C.totalLines}:{error:C.error||"Failed to fetch file logs"}}else return{error:(await x.json().catch(()=>({}))).error||`HTTP ${x.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function w(b){const m=document.createElement("div");m.className="log-entry",m.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=b.text;let x="INFO",C="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(x="ERROR",C="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(x="WARN",C="#f39c12"):S.match(/DEBUG/i)&&(x="DEBUG",C="var(--muted)");const O=`<span style="background: ${C==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${C}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${x}</span>`;return m.innerHTML=`
<div style="flex-shrink: 0;">${O}</div>
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div>
`,m}async function T(){if(g||!h||!E)return;const b=parseInt(document.getElementById("log-lines").value),m=document.getElementById("logs-content");try{const S=await A(h,b);if(S.error){m.innerHTML=`
Failed to fetch logs: ${escapeHtml(C.message)}
</div>`}}function L(w,g){f=w,m=g,l=!0,k=!1,v=!1,y();const C=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${g} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display=""),C.classList.add("show"),B(),i=setInterval(B,DC.POLL.LOGS)}async function A(w,g=100){try{const C=`/api/v1/logs/file?path=${encodeURIComponent(w)}&tail=${g}`,E=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await E.json().catch(()=>({}))).error||`HTTP ${E.status}`}}catch(C){return console.error("File logs fetch failed:",C),{error:C.message}}}function x(w){const g=document.createElement("div");g.className="log-entry",g.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=w.text;let E="INFO",S="var(--fg)";C.match(/ERROR|FATAL|CRITICAL/i)?(E="ERROR",S="var(--bad-fg)"):C.match(/WARN|WARNING/i)?(E="WARN",S="#f39c12"):C.match(/DEBUG/i)&&(E="DEBUG",S="var(--muted)");const N=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${E}</span>`;return g.innerHTML=`
<div style="flex-shrink: 0;">${N}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(C)}</div>
`,g}async function T(){if(v||!b||!k)return;const w=parseInt(document.getElementById("log-lines").value),g=document.getElementById("logs-content");try{const C=await A(b,w);if(C.error){g.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div>
</div>`;return}m.innerHTML=`
<div>${escapeHtml(C.error)}</div>
</div>`;return}g.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex: 1;">Log Output (${S.count} of ${S.totalLines} lines)</span>
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(x=>{const C=w(x);m.appendChild(C)}),m.scrollTop=m.scrollHeight):m.innerHTML+=`
<span style="flex: 1;">Log Output (${C.count} of ${C.totalLines} lines)</span>
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(E=>{const S=x(E);g.appendChild(S)}),g.scrollTop=g.scrollHeight):g.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file
</div>`}catch(S){m.innerHTML=`
</div>`}catch(C){g.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function $(b,m){h=b,r=m,E=!0,k=!1,g=!1;const S=document.getElementById("logs-modal"),x=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");x.textContent=`\u{1F4CB} ${m} - Application Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display="none"),S.classList.add("show"),T(),i=setInterval(T,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",b=>{const m=b.target.closest('[id$="-logs"]');if(!m)return;const S=m.id.replace("-logs","");SITE.dnsServers[S]&&u(S)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const b=document.getElementById("logs-pause");g?(b.textContent="\u25B6\uFE0F Resume",b.classList.add("paused")):(b.textContent="\u23F8\uFE0F Pause",b.classList.remove("paused"),a())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||a()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!k||!v||(d?p():l(v))}),document.getElementById("logs-modal")?.addEventListener("click",b=>{b.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",b=>{b.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=u})(),(function(){injectModal("service-edit-modal",`
Failed to fetch logs: ${escapeHtml(C.message)}
</div>`}}function $(w,g){b=w,a=g,k=!0,l=!1,v=!1;const C=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${g} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display="none"),C.classList.add("show"),T(),i=setInterval(T,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",w=>{const g=w.target.closest('[id$="-logs"]');if(!g)return;const C=g.id.replace("-logs","");SITE.dnsServers[C]&&p(C)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{v=!v;const w=document.getElementById("logs-pause");v?(w.textContent="\u25B6\uFE0F Resume",w.classList.add("paused")):(w.textContent="\u23F8\uFE0F Pause",w.classList.remove("paused"),s())}),document.getElementById("log-lines")?.addEventListener("change",()=>{v||s()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!l||!f||(d?y():c(f))}),document.getElementById("logs-modal")?.addEventListener("click",w=>{w.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",w=>{w.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=p})(),(function(){injectModal("service-edit-modal",`
<div id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3>
@@ -712,44 +712,44 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button>
</div>
</div>
</div>`)})(),(function(){async function o(k){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(k)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const r=await h.json();if(r.status==="success"){const E=document.getElementById("existing-ca-select");return E.innerHTML="",r.data.cas.length===0?E.innerHTML='<option value="">No CAs found in Caddyfile</option>':(E.innerHTML='<option value="">Select existing CA...</option>',r.data.cas.forEach(t=>{const d=document.createElement("option");typeof t=="object"?(d.value=t.id,d.textContent=t.displayName||t.name):(d.value=t,d.textContent=t),E.appendChild(d)})),r.data.cas}else throw new Error(r.message)}catch(h){console.error("Error loading CAs:",h);const r=document.getElementById("existing-ca-select");return r.innerHTML='<option value="">Error loading CAs</option>',[]}}function i(k){const{subdomain:h,port:r,ip:E,sslType:t,caName:d,existingCa:c,enableAuth:f,enableCors:e,customHeaders:a,upstreamPath:u,healthCheck:n,timeout:l,tailscaleOnly:p}=k;let s=`${buildDomain(h)} {
`;switch(p&&(s+=` @blocked not remote_ip 100.64.0.0/10
`,s+=` respond @blocked "Access denied. Tailscale connection required." 403
`),t){case"letsencrypt":break;case"caddy-managed":s+=` tls internal
`;break;case"existing-ca":c&&(s+=` tls {
ca ${c}
</div>`)})(),(function(){async function o(l){try{const b=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(l)}`);if(!b.ok)throw new Error(`Failed to load CAs: ${b.status}`);const a=await b.json();if(a.status==="success"){const k=document.getElementById("existing-ca-select");return k.innerHTML="",a.data.cas.length===0?k.innerHTML='<option value="">No CAs found in Caddyfile</option>':(k.innerHTML='<option value="">Select existing CA...</option>',a.data.cas.forEach(t=>{const d=document.createElement("option");typeof t=="object"?(d.value=t.id,d.textContent=t.displayName||t.name):(d.value=t,d.textContent=t),k.appendChild(d)})),a.data.cas}else throw new Error(a.message)}catch(b){console.error("Error loading CAs:",b);const a=document.getElementById("existing-ca-select");return a.innerHTML='<option value="">Error loading CAs</option>',[]}}function i(l){const{subdomain:b,port:a,ip:k,sslType:t,caName:d,existingCa:u,enableAuth:h,enableCors:e,customHeaders:s,upstreamPath:p,healthCheck:n,timeout:c,tailscaleOnly:y}=l;let r=`${buildDomain(b)} {
`;switch(y&&(r+=` @blocked not remote_ip 100.64.0.0/10
`,r+=` respond @blocked "Access denied. Tailscale connection required." 403
`),t){case"letsencrypt":break;case"caddy-managed":r+=` tls internal
`;break;case"existing-ca":u&&(r+=` tls {
ca ${u}
}
`);break;case"custom-ca":d&&(s+=` tls {
`);break;case"custom-ca":d&&(r+=` tls {
ca ${d}
}
`);break}if(f&&(s+=` basicauth {
`);break}if(h&&(r+=` basicauth {
admin $2a$14$hashed_password_here
}
`),e&&(s+=` header {
`,s+=` Access-Control-Allow-Origin "*"
`,s+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,s+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,s+=` }
`),a)try{const I=JSON.parse(a);s+=` header {
`,Object.entries(I).forEach(([B,L])=>{s+=` ${B} "${L}"
`}),s+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(s+=` health_uri ${n}
`),s+=` reverse_proxy ${E}:${r} {
`,u&&u!=="/"&&(s+=` rewrite ${u}
`),l&&l!==30&&(s+=` transport http {
`,s+=` dial_timeout ${l}s
`,s+=` response_header_timeout ${l}s
`,s+=` }
`),s+=` }
`,s+=`}
`,s}async function g(k,h,r=DC.DEFAULTS.TTL){const E=window.getToken(getPrimaryDnsId(),"admin");if(!E)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const t=buildDomain(k),d=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:t,ip:h,ttl:r,token:E,server:SITE.dnsIp})});if(!d.ok){const f=await d.text();throw new Error(`DNS API Error: ${d.status} - ${f}`)}const c=await d.json();if(!c.success)throw new Error(`DNS Error: ${c.error||"Unknown error"}`);return c}async function v(k){const h={id:k.subdomain,name:k.name,logo:k.logo||`/assets/${k.subdomain}.png`};try{const r=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!r.ok){const E=await r.json();throw new Error(E.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),h}catch(r){throw console.error("Failed to add service to config:",r),r}}async function y(k){const h=document.getElementById("service-subdomain-input").value.trim(),r=document.getElementById("service-ip-input").value.trim()||"localhost",E=document.getElementById("service-port-input").value.trim()||"80",t=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(h),upstream:`${r}:${E}`,config:k})}),d=await t.json();if(!t.ok||!d.success)throw new Error(d.error||`Caddy API Error: ${t.status}`);return d}window.loadExistingCAs=o,window.generateCaddyConfig=i,window.createDnsRecord=g,window.addServiceToConfig=v,window.addToCaddyfile=y})(),(function(){let o=null;function i(r){o=r;const E=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${r.name}`,document.getElementById("edit-service-name").value=r.name,document.getElementById("edit-service-url-display").textContent=r.url||buildServiceUrl(r.id),document.getElementById("edit-service-logo-preview").src=r.logo||`/assets/${r.id}.png`,document.getElementById("edit-subdomain").value=r.id,document.getElementById("edit-port").value=r.port||"",document.getElementById("edit-ip").value=r.ip||"localhost",document.getElementById("edit-tailscale-only").checked=r.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=r.logo||"",E.classList.add("show")}function g(){closeModal("service-edit-modal"),o=null}async function v(){if(!o)return;const r=document.getElementById("edit-subdomain").value.trim().toLowerCase(),E=document.getElementById("edit-service-name").value.trim(),t=document.getElementById("edit-port").value.trim(),d=document.getElementById("edit-ip").value.trim()||"localhost",c=document.getElementById("edit-tailscale-only").checked,f=document.getElementById("edit-logo-url").value.trim();if(!r){showNotification("Subdomain is required","warning");return}const e=o.id,a=[];if(r!==e&&a.push("subdomain"),E&&E!==o.name&&a.push("name"),t&&t!==String(o.port)&&a.push("port"),d!==o.ip&&a.push("ip"),c!==(o.tailscaleOnly||!1)&&a.push("tailscale"),f&&f!==o.logo&&a.push("logo"),a.length===0){g();return}const u=document.getElementById("service-edit-save");u.textContent="Saving...",u.disabled=!0;try{const l=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:r,name:E||o.name,port:t||o.port,ip:d,tailscaleOnly:c,logo:f||void 0})})).json();if(!l.success)throw new Error(l.error||"Failed to update service");const p=window.APPS.findIndex(s=>s.id===e);p!==-1&&(window.APPS[p]={...window.APPS[p],id:r,name:E||window.APPS[p].name,port:t||window.APPS[p].port,ip:d,tailscaleOnly:c,logo:f||window.APPS[p].logo}),g(),window.buildGrid(),window.refreshAll()}catch(n){console.error("Error saving service changes:",n),showNotification(`Error saving changes: ${n.message}`,"error")}finally{u.textContent="Save Changes",u.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async r=>{const E=r.target.files[0];if(!E)return;if(!E.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const t=new FileReader;t.onload=async d=>{const c=d.target.result;if(document.getElementById("edit-service-logo-preview").src=c,document.getElementById("edit-logo-url").value=c,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:c})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},t.readAsDataURL(E)}),document.getElementById("service-edit-cancel")?.addEventListener("click",g),document.getElementById("service-edit-save")?.addEventListener("click",v),document.getElementById("service-edit-modal")?.addEventListener("click",r=>{r.target.id==="service-edit-modal"&&g()});function y(r,E,t){return new Promise(d=>{const c=document.getElementById("delete-ser Line truncated
`),e&&(r+=` header {
`,r+=` Access-Control-Allow-Origin "*"
`,r+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,r+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,r+=` }
`),s)try{const I=JSON.parse(s);r+=` header {
`,Object.entries(I).forEach(([B,L])=>{r+=` ${B} "${L}"
`}),r+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(r+=` health_uri ${n}
`),r+=` reverse_proxy ${k}:${a} {
`,p&&p!=="/"&&(r+=` rewrite ${p}
`),c&&c!==30&&(r+=` transport http {
`,r+=` dial_timeout ${c}s
`,r+=` response_header_timeout ${c}s
`,r+=` }
`),r+=` }
`,r+=`}
`,r}async function v(l,b,a=DC.DEFAULTS.TTL){const k=window.getToken(getPrimaryDnsId(),"admin");if(!k)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const t=buildDomain(l),d=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:t,ip:b,ttl:a,token:k,server:SITE.dnsIp})});if(!d.ok){const h=await d.text();throw new Error(`DNS API Error: ${d.status} - ${h}`)}const u=await d.json();if(!u.success)throw new Error(`DNS Error: ${u.error||"Unknown error"}`);return u}async function f(l){const b={id:l.subdomain,name:l.name,logo:l.logo||`/assets/${l.subdomain}.png`};try{const a=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});if(!a.ok){const k=await a.json();throw new Error(k.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),b}catch(a){throw console.error("Failed to add service to config:",a),a}}async function m(l){const b=document.getElementById("service-subdomain-input").value.trim(),a=document.getElementById("service-ip-input").value.trim()||"localhost",k=document.getElementById("service-port-input").value.trim()||"80",t=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(b),upstream:`${a}:${k}`,config:l})}),d=await t.json();if(!t.ok||!d.success)throw new Error(d.error||`Caddy API Error: ${t.status}`);return d}window.loadExistingCAs=o,window.generateCaddyConfig=i,window.createDnsRecord=v,window.addServiceToConfig=f,window.addToCaddyfile=m})(),(function(){let o=null;function i(a){o=a;const k=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${a.name}`,document.getElementById("edit-service-name").value=a.name,document.getElementById("edit-service-url-display").textContent=a.url||buildServiceUrl(a.id),document.getElementById("edit-service-logo-preview").src=a.logo||`/assets/${a.id}.png`,document.getElementById("edit-subdomain").value=a.id,document.getElementById("edit-port").value=a.port||"",document.getElementById("edit-ip").value=a.ip||"localhost",document.getElementById("edit-tailscale-only").checked=a.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=a.logo||"",k.classList.add("show")}function v(){closeModal("service-edit-modal"),o=null}async function f(){if(!o)return;const a=document.getElementById("edit-subdomain").value.trim().toLowerCase(),k=document.getElementById("edit-service-name").value.trim(),t=document.getElementById("edit-port").value.trim(),d=document.getElementById("edit-ip").value.trim()||"localhost",u=document.getElementById("edit-tailscale-only").checked,h=document.getElementById("edit-logo-url").value.trim();if(!a){showNotification("Subdomain is required","warning");return}const e=o.id,s=[];if(a!==e&&s.push("subdomain"),k&&k!==o.name&&s.push("name"),t&&t!==String(o.port)&&s.push("port"),d!==o.ip&&s.push("ip"),u!==(o.tailscaleOnly||!1)&&s.push("tailscale"),h&&h!==o.logo&&s.push("logo"),s.length===0){v();return}const p=document.getElementById("service-edit-save");p.textContent="Saving...",p.disabled=!0;try{const c=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:a,name:k||o.name,port:t||o.port,ip:d,tailscaleOnly:u,logo:h||void 0})})).json();if(!c.success)throw new Error(c.error||"Failed to update service");const y=window.APPS.findIndex(r=>r.id===e);y!==-1&&(window.APPS[y]={...window.APPS[y],id:a,name:k||window.APPS[y].name,port:t||window.APPS[y].port,ip:d,tailscaleOnly:u,logo:h||window.APPS[y].logo}),v(),window.buildGrid(),window.refreshAll()}catch(n){console.error("Error saving service changes:",n),showNotification(`Error saving changes: ${n.message}`,"error")}finally{p.textContent="Save Changes",p.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async a=>{const k=a.target.files[0];if(!k)return;if(!k.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const t=new FileReader;t.onload=async d=>{const u=d.target.result;if(document.getElementById("edit-service-logo-preview").src=u,document.getElementById("edit-logo-url").value=u,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:u})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},t.readAsDataURL(k)}),document.getElementById("service-edit-cancel")?.addEventListener("click",v),document.getElementById("service-edit-save")?.addEventListener("click",f),document.getElementById("service-edit-modal")?.addEventListener("click",a=>{a.target.id==="service-edit-modal"&&v()});function m(a,k,t){return new Promise(d=>{const u=document.getElementById("delete-ser Line truncated
This will:
1. Pull the latest image
2. Stop the container
3. Recreate with same settings
The service will be briefly unavailable.`))try{d&&(d.textContent="\u{1F504}",d.disabled=!0,d.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${r}/update`,{method:"POST"})).json();if(e.success){const a=window.APPS.find(u=>u.id===t);a&&e.newContainerId&&(a.containerId=e.newContainerId),d&&(d.textContent="\u2705",d.title="Updated successfully!",setTimeout(()=>{d.textContent=c,d.disabled=!1,d.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${E} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(f){console.error("Update error:",f),d&&(d.textContent="\u274C",d.title="Update failed",setTimeout(()=>{d.textContent=c,d.disabled=!1,d.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${E}: ${f.message}`,"error")}}async function h(r,E){const t=window.APPS.find(s=>s.id===r),d=t?buildDomain(t.id):null,c=t?.containerId,f=await y(E||r,c,t?.containerId);if(f===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(f&&c)try{const s=new URLSearchParams({containerId:t.containerId,subdomain:t.id,ip:t.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(t.id)}?${s.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(s){console.error("App removal error:",s)}else if(f&&d){try{const s=t?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(d)}&type=A&ipAddress=${encodeURIComponent(s)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(s){e.dns=s.message}try{const I=await(await secureFetch(`/api/v1/site/${encodeURIComponent(d)}`,{method:"DELETE"})).json();e.caddy=I.success||I.error&&I.error.includes("not found")?"removed":I.error||"failed"}catch(s){e.caddy=s.message}}const a=window.APPS.findIndex(s=>s.id===r);a>-1&&(window.APPS.splice(a,1),e.dashboard=!0);try{const s=safeGetJSON("custom-apps",[]),I=s.findIndex(B=>B.id===r);I>-1&&(s.splice(I,1),safeSet("custom-apps",JSON.stringify(s)))}catch{}try{const I=await(await secureFetch(`/api/v1/services/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.service=I.success?"removed":I.error||"failed"}catch(s){e.service=s.message}window.buildGrid(),window.refreshAll();let u=!1,n=[];e.dashboard||(u=!0,n.push("\u2717 Failed to remove from dashboard"));const l=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],p=s=>!s||l.some(I=>s.toLowerCase().includes(I.toLowerCase()));e.container&&!p(e.container)&&(u=!0,n.push(`\u26A0 Container: ${e.container}`)),e.dns&&!p(e.dns)&&(u=!0,n.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!p(e.caddy)&&(u=!0,n.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!p(e.service)&&(u=!0,n.push(`\u26A0 Service File: ${e.service}`)),u&&showNotification(`Error deleting "${E||r}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=i,window.showDeleteModal=y,window.updateContainer=k,window.deleteService=h})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function i(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",a=document.getElementById("service-ip-input").value||v.lan||"localhost",u=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,l=document.getElementById("ca-name-input").value||"sami-ca",p=document.getElementById("existing-ca-select").value,s=document.getElementById("enable-auth").checked,I=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,w=document.getElementById("timeout-input").value||30,T=document.getElementById("dns-preview");T&&(T.textContent=`${buildDomain(e)} \u2192 ${a}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const b={subdomain:e,port:u,ip:a,sslType:n,caName:l,existingCa:p,enableAuth:s,enableCors:I,customHeaders:B,upstreamPath:L,healthCheck:A,timeout:w},m=window.generateCaddyConfig(b),S=document.getElementById("caddy-config-preview");S&&(S.value=m)}const v={localhost:"127.0.0.1",lan:"",tailscale:""};async function y(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const l=await n.json();l.lan&&(v.lan=l.lan),l.tailscale&&(v.tailscale=l.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),a=document.getElementById("quick-ip-tailscale");e&&(v.lan?(e.dataset.ip=v.lan,e.textContent=`LAN (${v.lan})`,e.title=`LAN IP: ${v.lan}`):e Line truncated
The service will be briefly unavailable.`))try{d&&(d.textContent="\u{1F504}",d.disabled=!0,d.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${a}/update`,{method:"POST"})).json();if(e.success){const s=window.APPS.find(p=>p.id===t);s&&e.newContainerId&&(s.containerId=e.newContainerId),d&&(d.textContent="\u2705",d.title="Updated successfully!",setTimeout(()=>{d.textContent=u,d.disabled=!1,d.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${k} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(h){console.error("Update error:",h),d&&(d.textContent="\u274C",d.title="Update failed",setTimeout(()=>{d.textContent=u,d.disabled=!1,d.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${k}: ${h.message}`,"error")}}async function b(a,k){const t=window.APPS.find(r=>r.id===a),d=t?buildDomain(t.id):null,u=t?.containerId,h=await m(k||a,u,t?.containerId);if(h===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(h&&u)try{const r=new URLSearchParams({containerId:t.containerId,subdomain:t.id,ip:t.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(t.id)}?${r.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(r){console.error("App removal error:",r)}else if(h&&d){try{const r=t?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(d)}&type=A&ipAddress=${encodeURIComponent(r)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(r){e.dns=r.message}try{const I=await(await secureFetch(`/api/v1/site/${encodeURIComponent(d)}`,{method:"DELETE"})).json();e.caddy=I.success||I.error&&I.error.includes("not found")?"removed":I.error||"failed"}catch(r){e.caddy=r.message}}const s=window.APPS.findIndex(r=>r.id===a);s>-1&&(window.APPS.splice(s,1),e.dashboard=!0);try{const r=safeGetJSON("custom-apps",[]),I=r.findIndex(B=>B.id===a);I>-1&&(r.splice(I,1),safeSet("custom-apps",JSON.stringify(r)))}catch{}try{const I=await(await secureFetch(`/api/v1/services/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.service=I.success?"removed":I.error||"failed"}catch(r){e.service=r.message}window.buildGrid(),window.refreshAll();let p=!1,n=[];e.dashboard||(p=!0,n.push("\u2717 Failed to remove from dashboard"));const c=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],y=r=>!r||c.some(I=>r.toLowerCase().includes(I.toLowerCase()));e.container&&!y(e.container)&&(p=!0,n.push(`\u26A0 Container: ${e.container}`)),e.dns&&!y(e.dns)&&(p=!0,n.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!y(e.caddy)&&(p=!0,n.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!y(e.service)&&(p=!0,n.push(`\u26A0 Service File: ${e.service}`)),p&&showNotification(`Error deleting "${k||a}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=i,window.showDeleteModal=m,window.updateContainer=l,window.deleteService=b})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function i(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function v(){const e=document.getElementById("service-subdomain-input").value||"subdomain",s=document.getElementById("service-ip-input").value||f.lan||"localhost",p=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,c=document.getElementById("ca-name-input").value||"sami-ca",y=document.getElementById("existing-ca-select").value,r=document.getElementById("enable-auth").checked,I=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,x=document.getElementById("timeout-input").value||30,T=document.getElementById("dns-preview");T&&(T.textContent=`${buildDomain(e)} \u2192 ${s}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const w={subdomain:e,port:p,ip:s,sslType:n,caName:c,existingCa:y,enableAuth:r,enableCors:I,customHeaders:B,upstreamPath:L,healthCheck:A,timeout:x},g=window.generateCaddyConfig(w),C=document.getElementById("caddy-config-preview");C&&(C.value=g)}const f={localhost:"127.0.0.1",lan:"",tailscale:""};async function m(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const c=await n.json();c.lan&&(f.lan=c.lan),c.tailscale&&(f.tailscale=c.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),s=document.getElementById("quick-ip-tailscale");e&&(f.lan?(e.dataset.ip=f.lan,e.textContent=`LAN (${f.lan})`,e.title=`LAN IP: ${f.lan}`):e Line truncated
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${I.self?.hostname} (${I.self?.ip})</span>
`,p.disabled=!1):I.installed?(l.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',p.disabled=!0):(l.innerHTML='<span style="color: var(--muted);">Not available</span>',p.disabled=!0)}catch{l.innerHTML='<span style="color: var(--muted);">Could not check</span>',p.disabled=!0}p.checked=!1,g()}function r(){const e=document.getElementById("service-type-local"),a=document.getElementById("service-type-external"),u=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),l=document.getElementById("tab-local"),p=document.getElementById("tab-external");function s(){e.checked?(u.style.display="grid",n.style.display="none",l&&(l.style.background="var(--accent)",l.style.color="var(--bg)"),p&&(p.style.background="transparent",p.style.color="var(--muted)")):(u.style.display="none",n.style.display="block",p&&(p.style.background="var(--accent)",p.style.color="var(--bg)"),l&&(l.style.background="transparent",l.style.color="var(--muted)"))}e?.addEventListener("change",s),a?.addEventListener("change",s)}function E(){const e=document.getElementById("service-name-input"),a=document.getElementById("service-subdomain-input"),u=document.getElementById("subdomain-preview");let n=!1;e?.addEventListener("input",()=>{const L=o(e.value);!n&&a&&(a.value=L),u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),a?.addEventListener("input",()=>{n=a.value!==o(e?.value||"");const L=a.value.trim()||o(e?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const l=document.getElementById("external-service-name"),p=document.getElementById("external-service-subdomain"),s=document.getElementById("external-subdomain-preview"),I=document.getElementById("external-domain-preview");let B=!1;l?.addEventListener("input",()=>{const L=o(l.value);!B&&p&&(p.value=L);const A=p?.value||L;s&&(s.textContent=A?`\u2192 ${buildDomain(A)}`:""),I&&(I.textContent=A?buildDomain(A):"")}),p?.addEventListener("input",()=>{B=p.value!==o(l?.value||"");const L=p.value.trim()||o(l?.value||"");s&&(s.textContent=L?`\u2192 ${buildDomain(L)}`:""),I&&(I.textContent=L?buildDomain(L):"")})}async function t(){const e=document.getElementById("external-service-name").value.trim(),a=document.getElementById("external-service-url").value.trim(),u=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),l=document.getElementById("external-service-icon").value.trim(),p=document.getElementById("external-create-dns").checked,s=document.getElementById("external-create-caddy").checked,I=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked;if(!e||!a){showNotification("Please fill in Name and External URL","warning");return}if(!u){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!a.startsWith("http://")&&!a.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(u);try{const w={dns:null,caddy:null,dashboard:!1};if(p)if(window.getToken(getPrimaryDnsId(),"admin"))try{const C=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:A,ip:I,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();w.dns=C.success?"created":C.error||"failed"}catch(x){w.dns=x.message}else w.dns="no admin token (configure in \u{1F511} Tokens)";if(s)try{const S={subdomain:u,externalUrl:a,preserveHost:B,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},C=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)})).json();w.caddy=C.success?"created":C.error||"failed"}catch(S){w.caddy=S.message}const T={id:u,name:e,url:`https://${A}`,externalUrl:a,logo:n||l||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(T),w.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],b=window.APPS.filter(S=>!$.includes(S.id));safeSet("custom-services",JSON.stringify(b));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(S){console.warn("Failed to save to services.json:",S)}window.buildGrid(),window.refreshAll(),d();const m=[`External service "${e}" added!`];p&&m.push(`DNS: ${w.dns==="created"?"\u2713":"\u26A0 "+w.dns}`),s&&m.push(`Caddy: ${w.caddy==="created"?"\u2713":"\u26A0 "+w.caddy}`),m.push(`Access at: https://${A}`),showNotification(m.join(" | "),"success",6e3)}catch(w){console.error("Failed to create external service:",w),showNotification(`Failed to create external service: ${w.message}`,"error")}}function d(){closeModal("a Line truncated
`,y.disabled=!1):I.installed?(c.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',y.disabled=!0):(c.innerHTML='<span style="color: var(--muted);">Not available</span>',y.disabled=!0)}catch{c.innerHTML='<span style="color: var(--muted);">Could not check</span>',y.disabled=!0}y.checked=!1,v()}function a(){const e=document.getElementById("service-type-local"),s=document.getElementById("service-type-external"),p=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),c=document.getElementById("tab-local"),y=document.getElementById("tab-external");function r(){e.checked?(p.style.display="grid",n.style.display="none",c&&(c.style.background="var(--accent)",c.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(p.style.display="none",n.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),c&&(c.style.background="transparent",c.style.color="var(--muted)"))}e?.addEventListener("change",r),s?.addEventListener("change",r)}function k(){const e=document.getElementById("service-name-input"),s=document.getElementById("service-subdomain-input"),p=document.getElementById("subdomain-preview");let n=!1;e?.addEventListener("input",()=>{const L=o(e.value);!n&&s&&(s.value=L),p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),v()}),s?.addEventListener("input",()=>{n=s.value!==o(e?.value||"");const L=s.value.trim()||o(e?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),v()});const c=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),r=document.getElementById("external-subdomain-preview"),I=document.getElementById("external-domain-preview");let B=!1;c?.addEventListener("input",()=>{const L=o(c.value);!B&&y&&(y.value=L);const A=y?.value||L;r&&(r.textContent=A?`\u2192 ${buildDomain(A)}`:""),I&&(I.textContent=A?buildDomain(A):"")}),y?.addEventListener("input",()=>{B=y.value!==o(c?.value||"");const L=y.value.trim()||o(c?.value||"");r&&(r.textContent=L?`\u2192 ${buildDomain(L)}`:""),I&&(I.textContent=L?buildDomain(L):"")})}async function t(){const e=document.getElementById("external-service-name").value.trim(),s=document.getElementById("external-service-url").value.trim(),p=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),c=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,r=document.getElementById("external-create-caddy").checked,I=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked;if(!e||!s){showNotification("Please fill in Name and External URL","warning");return}if(!p){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!s.startsWith("http://")&&!s.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(p);try{const x={dns:null,caddy:null,dashboard:!1};if(y)if(window.getToken(getPrimaryDnsId(),"admin"))try{const S=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:A,ip:I,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();x.dns=S.success?"created":S.error||"failed"}catch(E){x.dns=E.message}else x.dns="no admin token (configure in \u{1F511} Tokens)";if(r)try{const C={subdomain:p,externalUrl:s,preserveHost:B,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},S=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(C)})).json();x.caddy=S.success?"created":S.error||"failed"}catch(C){x.caddy=C.message}const T={id:p,name:e,url:`https://${A}`,externalUrl:s,logo:n||c||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(T),x.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(C=>!$.includes(C.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(C){console.warn("Failed to save to services.json:",C)}window.buildGrid(),window.refreshAll(),d();const g=[`External service "${e}" added!`];y&&g.push(`DNS: ${x.dns==="created"?"\u2713":"\u26A0 "+x.dns}`),r&&g.push(`Caddy: ${x.caddy==="created"?"\u2713":"\u26A0 "+x.caddy}`),g.push(`Access at: https://${A}`),showNotification(g.join(" | "),"success",6e3)}catch(x){console.error("Failed to create external service:",x),showNotification(`Failed to create external service: ${x.message}`,"error")}}function d(){closeModal("a Line truncated
+305 -140
View File
@@ -90,44 +90,44 @@
<button id="logo-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);const y=document.getElementById("logo-modal"),h=document.getElementById("logo-preview-dark"),L=document.getElementById("logo-preview-light"),T=document.getElementById("logo-status"),B=document.getElementById("logo-same-both"),N=document.getElementById("logo-dual-uploads"),D=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),$=document.getElementById("logo-upload-light"),P=document.getElementById("logo-upload-single"),C=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),O=document.getElementById("dashboard-title"),z=DC.NAME;let S=null,E=null,k=null,b="left",m=z;B?.addEventListener("change",()=>{B.checked?(N.style.display="none",D.style.display="",S=null,E=null):(N.style.display="flex",D.style.display="none",k=null)});function f(o,s){if(!o||!o.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const u=new FileReader;u.onload=l=>s(l.target.result),u.readAsDataURL(o)}A?.addEventListener("change",o=>{f(o.target.files[0],s=>{S=s,h.src=s,T.textContent="New dark logo ready to save"})}),$?.addEventListener("change",o=>{f(o.target.files[0],s=>{E=s,L.src=s,T.textContent="New light logo ready to save"})}),P?.addEventListener("change",o=>{f(o.target.files[0],s=>{k=s,h.src=s,L.src=s,T.textContent="New logo ready to save (both themes)"})});function c(o){x.setAttribute("data-logo-pos",o),document.querySelectorAll(".logo-pos-btn").forEach(s=>{s.style.background=s.dataset.pos===o?"var(--accent)":"var(--card-bg)",s.style.color=s.dataset.pos===o?"white":"var(--fg)"})}function d(o){m=o||z,document.title=m;const s=document.querySelector(".dashboard-title");s&&(s.textContent=m)}async function a(){try{const o=await fetch("/api/v1/logo");if(o.ok){const s=await o.json();s.customLogoDark&&(C.src=s.customLogoDark,h.src=s.customLogoDark),s.customLogoLight&&(H.src=s.customLogoLight,L.src=s.customLogoLight),!s.customLogoDark&&!s.customLogoLight&&s.customLogo&&(C.src=s.customLogo,H.src=s.customLogo,h.src=s.customLogo,L.src=s.customLogo),s.isDefault||(T.textContent="Using custom logo"),s.position&&(b=s.position,c(s.position)),s.dashboardTitle&&d(s.dashboardTitle)}}catch(o){console.warn("Could not load custom logo:",o.message)}}document.querySelectorAll(".logo-pos-btn").forEach(o=>{o.addEventListener("click",()=>{b=o.dataset.pos,c(b)})}),document.getElementById("brand")?.addEventListener("click",()=>{S=null,E=null,k=null,A&&(A.value=""),$&&($.value=""),P&&(P.value=""),B&&(B.checked=!1),N.style.display="flex",D.style.display="none",h.src=C.src,L.src=H.src;const o=C.src.includes("custom-logo")||H.src.includes("custom-logo");T.textContent=o?"Using custom logo":"Using default logos",c(b),O.value=m,y.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const o=O.value.trim()||z,s={position:b,dashboardTitle:o};B?.checked&&k?(s.dataDark=k,s.dataLight=k):(S&&(s.dataDark=S),E&&(s.dataLight=E));const u=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(u.ok){const l=await u.json(),g="?t="+Date.now();l.pathDark&&(C.src=l.pathDark+g,h.src=l.pathDark+g),l.pathLight&&(H.src=l.pathLight+g,L.src=l.pathLight+g),c(b),d(o),y.classList.remove("show")}else{const l=await u.json();showNotification("Failed to save: "+l.error,"error")}}catch(o){showNotification("Error saving: "+o.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
</div>`);const f=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),M=document.getElementById("logo-preview-light"),k=document.getElementById("logo-status"),S=document.getElementById("logo-same-both"),D=document.getElementById("logo-dual-uploads"),B=document.getElementById("logo-single-upload"),C=document.getElementById("logo-upload-dark"),m=document.getElementById("logo-upload-light"),h=document.getElementById("logo-upload-single"),u=document.querySelector("#brand .brand-logo-dark"),y=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),O=document.getElementById("dashboard-title"),A=DC.NAME;let N=null,z=null,H=null,L="left",g=A;S?.addEventListener("change",()=>{S.checked?(D.style.display="none",B.style.display="",N=null,z=null):(D.style.display="flex",B.style.display="none",H=null)});function I(o,i){if(!o||!o.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const v=new FileReader;v.onload=d=>i(d.target.result),v.readAsDataURL(o)}C?.addEventListener("change",o=>{I(o.target.files[0],i=>{N=i,E.src=i,k.textContent="New dark logo ready to save"})}),m?.addEventListener("change",o=>{I(o.target.files[0],i=>{z=i,M.src=i,k.textContent="New light logo ready to save"})}),h?.addEventListener("change",o=>{I(o.target.files[0],i=>{H=i,E.src=i,M.src=i,k.textContent="New logo ready to save (both themes)"})});function c(o){x.setAttribute("data-logo-pos",o),document.querySelectorAll(".logo-pos-btn").forEach(i=>{i.style.background=i.dataset.pos===o?"var(--accent)":"var(--card-bg)",i.style.color=i.dataset.pos===o?"white":"var(--fg)"})}function l(o){g=o||A,document.title=g;const i=document.querySelector(".dashboard-title");i&&(i.textContent=g)}async function a(){try{const o=await fetch("/api/v1/logo");if(o.ok){const i=await o.json();i.customLogoDark&&(u.src=i.customLogoDark,E.src=i.customLogoDark),i.customLogoLight&&(y.src=i.customLogoLight,M.src=i.customLogoLight),!i.customLogoDark&&!i.customLogoLight&&i.customLogo&&(u.src=i.customLogo,y.src=i.customLogo,E.src=i.customLogo,M.src=i.customLogo),i.isDefault||(k.textContent="Using custom logo"),i.position&&(L=i.position,c(i.position)),i.dashboardTitle&&l(i.dashboardTitle)}}catch(o){console.warn("Could not load custom logo:",o.message)}}document.querySelectorAll(".logo-pos-btn").forEach(o=>{o.addEventListener("click",()=>{L=o.dataset.pos,c(L)})}),document.getElementById("brand")?.addEventListener("click",()=>{N=null,z=null,H=null,C&&(C.value=""),m&&(m.value=""),h&&(h.value=""),S&&(S.checked=!1),D.style.display="flex",B.style.display="none",E.src=u.src,M.src=y.src;const o=u.src.includes("custom-logo")||y.src.includes("custom-logo");k.textContent=o?"Using custom logo":"Using default logos",c(L),O.value=g,f.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const o=O.value.trim()||A,i={position:L,dashboardTitle:o};S?.checked&&H?(i.dataDark=H,i.dataLight=H):(N&&(i.dataDark=N),z&&(i.dataLight=z));const v=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(v.ok){const d=await v.json(),w="?t="+Date.now();d.pathDark&&(u.src=d.pathDark+w,E.src=d.pathDark+w),d.pathLight&&(y.src=d.pathLight+w,M.src=d.pathLight+w),c(L),l(o),f.classList.remove("show")}else{const d=await v.json();showNotification("Failed to save: "+d.error,"error")}}catch(o){showNotification("Error saving: "+o.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&&(C.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",h.src="/assets/dashcaddy-logo-dark.png",L.src="/assets/dashcaddy-logo-light.png",T.textContent="Using default logos",S=null,E=null,k=null,O.value=z,d(z),b="left",c("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const u=document.querySelector('link[rel="icon"]'),l=document.getElementById("favicon-preview"),g=document.getElementById("favicon-status");u&&(u.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),l&&(l.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),g&&(g.textContent="Using DashCaddy favicon"),r=null}}catch(o){showNotification("Error resetting branding: "+o.message,"error")}}),wireModal(y,document.getElementById("logo-cancel"));const n=document.getElementById("favicon-preview"),e=document.getElementById("favicon-status"),t=document.getElementById("favicon-upload"),i=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(i.rel="icon",i.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(i));async function p(){try{const o=await fetch("/api/v1/favicon");if(o.ok){const s=await o.json();s.customFavicon&&(i.href=s.customFavicon+"?t="+Date.now(),n.src=s.customFavicon+"?t="+Date.now(),e.textContent="Using custom favicon")}}catch(o){console.warn("Could not load custom favicon:",o.message)}}t?.addEventListener("change",o=>{const s=o.target.files[0];if(!s)return;if(!s.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),t.value="";return}const u=new FileReader;u.onload=l=>{r=l.target.result,n.src=r,e.textContent="New favicon ready to save"},u.readAsDataURL(s)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const o=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(o.ok){const s=await o.json();i.href=s.path+"?t="+Date.now(),n.src=s.path+"?t="+Date.now(),e.textContent="Using custom favicon",r=null}else{const s=await o.json();showNotification("Failed to save favicon: "+s.error,"error")}}catch(o){showNotification("Error saving favicon: "+o.message,"error")}}),p(),a();const v=document.getElementById("settings-timezone");v&&(new MutationObserver(()=>{y.classList.contains("show")&&v.options.length===0&&(async()=>{let s;try{const u=await fetch("/api/v1/config");u.ok&&(s=(await u.json()).timezone)}catch{}window.populateTimezoneSelect(v,s)})()}).observe(y,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const s=v.value;if(s)try{const u=await fetch("/api/v1/config");if(!u.ok)return;const l=await u.json();l.timezone=s,l.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)})}catch(u){console.warn("Failed to save timezone:",u.message)}}))})(),window.populateTimezoneSelect=function(y,h){const L=Intl.supportedValuesOf("timeZone"),T=h||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";y.innerHTML="";for(const B of L){const N=document.createElement("option");N.value=B,N.textContent=B.replace(/_/g," "),B===T&&(N.selected=!0),y.appendChild(N)}},(function(){let y="homelab",h=null;async function L(){try{const f=await fetch("/api/v1/config");if(f.ok&&(h=await f.json(),h&&h.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)}L();const T=document.getElementById("setup-timezone");T&&window.populateTimezoneSelect(T);function B(m){document.querySelectorAll(".setup-step").forEach(c=>{c.style.display="none"});const f=document.getElementById(m);f&&(f.style.display="block")}function N(){const m=document.getElementById("setup-summary-content");if(!m)return;let f='<div style="display: grid; gap: 20px;">';if(y==="homelab"){const d=document.getElementById("setup-tld")?.value?.trim()||".home",a=document.getElementById("setup-ca-name")?.value?.trim()||"",n=document.getElementById("setup-dns-ip")?.value?.trim()||"",e=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&&(u.src="/assets/dashcaddy-logo-dark.png",y.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",k.textContent="Using default logos",N=null,z=null,H=null,O.value=A,l(A),L="left",c("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const v=document.querySelector('link[rel="icon"]'),d=document.getElementById("favicon-preview"),w=document.getElementById("favicon-status");v&&(v.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),d&&(d.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),w&&(w.textContent="Using DashCaddy favicon"),r=null}}catch(o){showNotification("Error resetting branding: "+o.message,"error")}}),wireModal(f,document.getElementById("logo-cancel"));const n=document.getElementById("favicon-preview"),e=document.getElementById("favicon-status"),t=document.getElementById("favicon-upload"),s=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(s.rel="icon",s.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(s));async function p(){try{const o=await fetch("/api/v1/favicon");if(o.ok){const i=await o.json();i.customFavicon&&(s.href=i.customFavicon+"?t="+Date.now(),n.src=i.customFavicon+"?t="+Date.now(),e.textContent="Using custom favicon")}}catch(o){console.warn("Could not load custom favicon:",o.message)}}t?.addEventListener("change",o=>{const i=o.target.files[0];if(!i)return;if(!i.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),t.value="";return}const v=new FileReader;v.onload=d=>{r=d.target.result,n.src=r,e.textContent="New favicon ready to save"},v.readAsDataURL(i)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const o=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(o.ok){const i=await o.json();s.href=i.path+"?t="+Date.now(),n.src=i.path+"?t="+Date.now(),e.textContent="Using custom favicon",r=null}else{const i=await o.json();showNotification("Failed to save favicon: "+i.error,"error")}}catch(o){showNotification("Error saving favicon: "+o.message,"error")}}),p(),a();const b=document.getElementById("settings-timezone");b&&(new MutationObserver(()=>{f.classList.contains("show")&&b.options.length===0&&(async()=>{let i;try{const v=await fetch("/api/v1/config");v.ok&&(i=(await v.json()).timezone)}catch{}window.populateTimezoneSelect(b,i)})()}).observe(f,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const i=b.value;if(i)try{const v=await fetch("/api/v1/config");if(!v.ok)return;const d=await v.json();d.timezone=i,d.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)})}catch(v){console.warn("Failed to save timezone:",v.message)}}))})(),window.populateTimezoneSelect=function(f,E){const M=Intl.supportedValuesOf("timeZone"),k=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f.innerHTML="";for(const S of M){const D=document.createElement("option");D.value=S,D.textContent=S.replace(/_/g," "),S===k&&(D.selected=!0),f.appendChild(D)}},(function(){let f="homelab",E=null;async function M(){try{const I=await fetch("/api/v1/config");if(I.ok&&(E=await I.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(I){console.warn("Could not fetch server config, checking localStorage fallback:",I.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}M();const k=document.getElementById("setup-timezone");k&&window.populateTimezoneSelect(k);function S(g){document.querySelectorAll(".setup-step").forEach(c=>{c.style.display="none"});const I=document.getElementById(g);I&&(I.style.display="block")}function D(){const g=document.getElementById("setup-summary-content");if(!g)return;let I='<div style="display: grid; gap: 20px;">';if(f==="homelab"){const l=document.getElementById("setup-tld")?.value?.trim()||".home",a=document.getElementById("setup-ca-name")?.value?.trim()||"",n=document.getElementById("setup-dns-ip")?.value?.trim()||"",e=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;I+=`
<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> ${d}</div>
<div><strong>TLD:</strong> ${l}</div>
<div><strong>Certificate Authority:</strong> ${a}</div>
<div><strong>DNS Server:</strong> ${n}:${e}</div>
<div><strong>Example URLs:</strong> https://uptime${d}, https://nextcloud${d}</div>
<div><strong>Example URLs:</strong> https://uptime${l}, https://nextcloud${l}</div>
</div>
</div>
`}else if(y==="simple"){const d=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";f+=`
`}else if(f==="simple"){const l=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";I+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Access Method:</strong> IP:Port only</div>
<div><strong>Default IP:</strong> ${d}</div>
<div><strong>Default IP:</strong> ${l}</div>
<div><strong>SSL:</strong> None (HTTP only)</div>
<div><strong>Example URLs:</strong> http://${d}:8080, http://${d}:3000</div>
<div><strong>Example URLs:</strong> http://${l}:8080, http://${l}:3000</div>
</div>
</div>
`}else if(y==="public"){const d=document.getElementById("setup-public-domain")?.value?.trim()||"",a=document.getElementById("setup-public-email")?.value?.trim()||"",n=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=n==="subdirectory"?`https://${d}/sonarr, https://${d}/grafana`:`https://sonarr.${d}, https://grafana.${d}`;f+=`
`}else if(f==="public"){const l=document.getElementById("setup-public-domain")?.value?.trim()||"",a=document.getElementById("setup-public-email")?.value?.trim()||"",n=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=n==="subdirectory"?`https://${l}/sonarr, https://${l}/grafana`:`https://sonarr.${l}, https://grafana.${l}`;I+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${d}</div>
<div><strong>Domain:</strong> ${l}</div>
<div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${a}</div>
<div><strong>Routing:</strong> ${n==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${e}</div>
</div>
</div>
`}const c=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f+=`
`}const c=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";I+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${c.replace(/_/g," ")}</div>
</div>
`,f+="</div>",m.innerHTML=f,B("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):(console.error("Failed to save config to server:",f.status),!1)}catch(f){return console.error("Error saving config to server:",f),!1}}async function A(){const m={setupComplete:!0,configurationType:y,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};y==="homelab"?(m.tld=document.getElementById("setup-tld")?.value?.trim()||".home",m.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",m.dns={provider:"technitium",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"}):y==="simple"?(m.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",m.defaults={dnsType:"none",sslType:"none",targetIP:m.defaultIP}):y==="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 c=y==="homelab"?"Professional Home Lab":y==="simple"?"Simple Setup":"Public Server",d=f?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${d}`,"success",5e3),setTimeout(()=>location.reload(),500)}const $=document.getElementById("setup-step-1-next");$&&($.onclick=function(m){m.preventDefault();const f=document.querySelector('input[name="config-type"]:checked');f&&(y=f.value),B(y==="homelab"?"setup-step-homelab":y==="simple"?"setup-step-simple":y==="public"?"setup-step-public":"setup-step-homelab")});const P=document.getElementById("setup-skip");P&&(P.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 C=document.getElementById("setup-tld");C&&(C.oninput=function(m){const f=m.target.value||".home",c=document.getElementById("tld-preview"),d=document.getElementById("tld-preview-2");c&&(c.textContent=f),d&&(d.textContent=f)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(m){m.preventDefault(),B("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",d=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(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!d){showNotification("Please enter your DNS server IP address","warning");return}N()});const O=document.getElementById("setup-simple-back");O&&(O.onclick=function(m){m.preventDefault(),B("setup-step-1")});const z=document.getElementById("setup-simple-next");z&&(z.onclick=function(m){m.preventDefault(),N()}),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 S=document.getElementById("setup-public-back");S&&(S.onclick=function(m){m.preventDefault(),B("setup-step-1")});const E=document.getElementById("setup-public-next");E&&(E.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!f){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}N()});const k=document.getElementById("setup-summary-back");k&&(k.onclick=function(m){m.preventDefault(),y==="homelab"?B("setup-step-homelab"):y==="simple"?B("setup-step-simple"):y==="public"&&B("setup-step-public")});const b=document.getElementById("setup-finish");b&&(b.onclick=function(m){m.preventDefault() Line truncated
`,I+="</div>",g.innerHTML=I,S("setup-step-summary")}async function B(g){try{const I=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(g)});return I.ok?(await I.json(),!0):(console.error("Failed to save config to server:",I.status),!1)}catch(I){return console.error("Error saving config to server:",I),!1}}async function C(){const g={setupComplete:!0,configurationType:f,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};f==="homelab"?(g.tld=document.getElementById("setup-tld")?.value?.trim()||".home",g.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",g.dns={provider:"technitium",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()||""},g.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):f==="simple"?(g.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",g.defaults={dnsType:"none",sslType:"none",targetIP:g.defaultIP}):f==="public"&&(g.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",g.email=document.getElementById("setup-public-email")?.value?.trim()||"",g.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",g.defaults={dnsType:g.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const I=await B(g);safeSet("dashcaddy-config",JSON.stringify(g)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const c=f==="homelab"?"Professional Home Lab":f==="simple"?"Simple Setup":"Public Server",l=I?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${l}`,"success",5e3),setTimeout(()=>location.reload(),500)}const m=document.getElementById("setup-step-1-next");m&&(m.onclick=function(g){g.preventDefault();const I=document.querySelector('input[name="config-type"]:checked');I&&(f=I.value),S(f==="homelab"?"setup-step-homelab":f==="simple"?"setup-step-simple":f==="public"?"setup-step-public":"setup-step-homelab")});const h=document.getElementById("setup-skip");h&&(h.onclick=async function(g){g.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await B({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const u=document.getElementById("setup-tld");u&&(u.oninput=function(g){const I=g.target.value||".home",c=document.getElementById("tld-preview"),l=document.getElementById("tld-preview-2");c&&(c.textContent=I),l&&(l.textContent=I)});const y=document.getElementById("setup-homelab-back");y&&(y.onclick=function(g){g.preventDefault(),S("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(g){g.preventDefault();const I=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",l=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!I||!I.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!l){showNotification("Please enter your DNS server IP address","warning");return}D()});const O=document.getElementById("setup-simple-back");O&&(O.onclick=function(g){g.preventDefault(),S("setup-step-1")});const A=document.getElementById("setup-simple-next");A&&(A.onclick=function(g){g.preventDefault(),D()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(g){g.onchange=function(){var I=document.getElementById("dns-requirement-note");I&&(I.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const N=document.getElementById("setup-public-back");N&&(N.onclick=function(g){g.preventDefault(),S("setup-step-1")});const z=document.getElementById("setup-public-next");z&&(z.onclick=function(g){g.preventDefault();const I=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!I){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}D()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(g){g.preventDefault(),f==="homelab"?S("setup-step-homelab"):f==="simple"?S("setup-step-simple"):f==="public"&&S("setup-step-public")});const L=document.getElementById("setup-finish");L&&(L.onclick=function(g){g.preventDefault() 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>
@@ -306,6 +306,22 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</div>
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
</div>
<div style="margin-top: 12px;">
<label class="form-label-accent-sm">\u2699\uFE0F Resource Limits</label>
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
Optional CPU and memory constraints. Leave at 0 for unlimited.
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
</div>
<div>
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
</div>
</div>
</div>
</div>
</details>
</div>
@@ -317,52 +333,52 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button>
</div>
</div>
</div>`);const y="custom-apps";let h=null,L=null;const T=document.getElementById("app-selector-modal"),B=document.getElementById("app-selector-grid");async function N(){try{const t=await(await fetch("/api/v1/apps/templates")).json();if(t.success)return h=t.templates,L=t.categories,!0}catch(e){console.error("Failed to fetch app templates:",e)}return!1}async function D(e){try{return await(await fetch(`/api/v1/apps/ports/${e}/check`)).json()}catch(t){return console.error("Failed to check port:",t),{available:!0}}}async function A(e){try{const i=await(await fetch(`/api/v1/apps/ports/${e}/suggest`)).json();if(i.success)return i.suggestedPort}catch(t){console.error("Failed to get suggested port:",t)}return e}async function $(){if(B.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!h&&!await N()){B.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}B.innerHTML="";const e={};for(const[i,r]of Object.entries(h)){const p=r.category||"Other";e[p]||(e[p]=[]),e[p].push({id:i,...r})}const t=L?Object.keys(L):Object.keys(e).sort();for(const i of t){const r=e[i];if(!r||r.length===0)continue;r.sort((o,s)=>(s.popularity||0)-(o.popularity||0));const p=document.createElement("div");p.className="app-category-header";const v=L?.[i]||{};p.innerHTML=`${escapeHtml(v.icon||"")} ${escapeHtml(i)}`,v.color&&(p.style.borderBottomColor=v.color),B.appendChild(p),r.forEach(o=>{const s=document.createElement("div");s.className="app-option";const u=o.isDashboardWidget,l=u&&safeGet("widget-"+o.id+"-enabled")!=="false",g=u?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${l?"#2ecc7130":"#e74c3c30"}; color: ${l?"#2ecc71":"#e74c3c"}; font-weight: 600;">${l?"ON":"OFF"}</div>`:"",I=!u&&o.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(o.difficulty)}</div>`:"";s.innerHTML=`
</div>`);const f="custom-apps";let E=null,M=null;const k=document.getElementById("app-selector-modal"),S=document.getElementById("app-selector-grid");async function D(){try{const t=await(await fetch("/api/v1/apps/templates")).json();if(t.success)return E=t.templates,M=t.categories,!0}catch(e){console.error("Failed to fetch app templates:",e)}return!1}async function B(e){try{return await(await fetch(`/api/v1/apps/ports/${e}/check`)).json()}catch(t){return console.error("Failed to check port:",t),{available:!0}}}async function C(e){try{const s=await(await fetch(`/api/v1/apps/ports/${e}/suggest`)).json();if(s.success)return s.suggestedPort}catch(t){console.error("Failed to get suggested port:",t)}return e}async function m(){if(S.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!E&&!await D()){S.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}S.innerHTML="";const e={};for(const[s,r]of Object.entries(E)){const p=r.category||"Other";e[p]||(e[p]=[]),e[p].push({id:s,...r})}const t=M?Object.keys(M):Object.keys(e).sort();for(const s of t){const r=e[s];if(!r||r.length===0)continue;r.sort((o,i)=>(i.popularity||0)-(o.popularity||0));const p=document.createElement("div");p.className="app-category-header";const b=M?.[s]||{};p.innerHTML=`${escapeHtml(b.icon||"")} ${escapeHtml(s)}`,b.color&&(p.style.borderBottomColor=b.color),S.appendChild(p),r.forEach(o=>{const i=document.createElement("div");i.className="app-option";const v=o.isDashboardWidget,d=v&&safeGet("widget-"+o.id+"-enabled")!=="false",w=v?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${d?"#2ecc7130":"#e74c3c30"}; color: ${d?"#2ecc71":"#e74c3c"}; font-weight: 600;">${d?"ON":"OFF"}</div>`:"",T=!v&&o.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(o.difficulty)}</div>`:"";i.innerHTML=`
<div class="app-option-icon">${escapeHtml(o.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(o.name)}</div>
<div class="app-option-desc">${escapeHtml(o.description||"")}</div>
${g}${I}
`,u?s.onclick=()=>P(o,s):s.onclick=()=>C(o),B.appendChild(s)})}window.renderRecipeCards&&await window.renderRecipeCards(B)}function P(e,t){const i="widget-"+e.id+"-enabled",p=!(safeGet(i)!=="false");safeSet(i,String(p));const v=e.widgetSelector;if(v){const s=document.querySelector(v);s&&(s.style.display=p?"":"none")}const o=t.querySelector('div[style*="border-radius: 4px"]');o&&(o.textContent=p?"ON":"OFF",o.style.background=p?"#2ecc7130":"#e74c3c30",o.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${e.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function C(e){const t=document.getElementById("app-deploy-modal"),i=document.getElementById("app-deploy-title"),r=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),v=document.getElementById("deploy-ip"),o=document.getElementById("deploy-port"),s=document.getElementById("deploy-tailscale-only"),u=document.getElementById("tailscale-status");try{const U=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e.id})})).json();if(U.success&&U.exists){const J=U.container;confirm(`Found existing ${e.name} container:
${w}${T}
`,v?i.onclick=()=>h(o,i):i.onclick=()=>u(o),S.appendChild(i)})}window.renderRecipeCards&&await window.renderRecipeCards(S)}function h(e,t){const s="widget-"+e.id+"-enabled",p=!(safeGet(s)!=="false");safeSet(s,String(p));const b=e.widgetSelector;if(b){const i=document.querySelector(b);i&&(i.style.display=p?"":"none")}const o=t.querySelector('div[style*="border-radius: 4px"]');o&&(o.textContent=p?"ON":"OFF",o.style.background=p?"#2ecc7130":"#e74c3c30",o.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${e.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function u(e){const t=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),r=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),b=document.getElementById("deploy-ip"),o=document.getElementById("deploy-port"),i=document.getElementById("deploy-tailscale-only"),v=document.getElementById("tailscale-status");try{const q=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e.id})})).json();if(q.success&&q.exists){const W=q.container;confirm(`Found existing ${e.name} container:
Container: ${J.name}
Status: ${J.status}
Port: ${J.primaryPort||"N/A"}
Container: ${W.name}
Status: ${W.status}
Port: ${W.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.`)&&(e._useExisting=!0,e._existingContainer=J)}}catch{}i.textContent=`Deploy ${e.name}`;const l=e.subdomain||e.id.replace(/-/g,"");r.value=l;const g=document.getElementById("subpath-compat-warning");if(g)if(SITE.routingMode==="subdirectory"){const F=e.subpathSupport||"strip";F==="none"?(g.style.display="block",g.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+e.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):F==="strip"?(g.style.display="block",g.innerHTML='<span style="color: var(--muted);">&#9432; '+e.name+" has unverified subdirectory support. It may require additional configuration.</span>"):g.style.display="none"}else g.style.display="none";const I=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),w=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),M=document.querySelector(`input[name="dns-type"][value="${I}"]`),R=document.querySelector(`input[name="ssl-type"][value="${w}"]`);M?M.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,R?R.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,v.value=SITE.defaults.targetIP||"localhost",s.checked=!1;const q=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),W=_?.querySelector("div");if(_&&W&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const F=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,U=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;F&&!F.dataset.moved&&(W.appendChild(F),F.dataset.moved="1"),U&&!U.dataset.moved&&(W.appendChild(U),U.dataset.moved="1")}const j=document.getElementById("media-path-section"),V=document.getElementById("deploy-media-path"),se=document.getElementById("media-path-description");if(e.mediaMount){j.style.display="block",V.value="",V.placeholder="/media/Movies, /media/TVShows or click Browse";const F=document.getElementById("detected-mounts-container"),U=document.getElementById("detected-mounts-list");try{const G=await(await fetch("/api/v1/media/detected-mounts")).json();if(G.success&&G.mounts.length>0){F.style.display="block",U.innerHTML="";const ee=[...new Set(G.mounts.map(Z=>Z.hostPath))];V.value=ee.join(", "),G.mounts.forEach(Z=>{const Y=document.createElement("button");Y.type="button";const le=ee.includes(Z.hostPath);Y.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Y.innerHTML=`<span style="font-weight: 500;">${escapeHtml(Z.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(Z.sourceImage)}</span>`,Y.title=`${Z.hostPath} (from ${Z.sourceContainer})`,Y.onclick=()=>{const re=V.value.split(",").map(de=>de.trim()).filter(de=>de),ce=re.indexOf(Z.hostPath);ce>=0?(re.splice(ce,1),Y.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(re.push(Z.hostPath),Y.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),V.value=re.join(", ")},U.appendChild(Y)})}else F.style.display="none"}catch{F.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(V)}}else j.style.display="none",V.value="",document.getElementById("detected-mounts-container").style.display="none";const K=document.getElementById("plex-claim-section");K&&(e.id==="plex"||e.claimToken?(K.style.display="block",document.getElementById("deploy-plex-claim").value=""):K.style.display="none");const Q=document.getElementById("volume-mounts-section"),te=document.getElementById("volume-mounts-list");if(te.innerHTML="",e.docker?.volumes?.length){const F=e.mediaMount?.containerPath,U=e.docker.volumes.filter(J=>!J.includes("{{MEDIA_PATH}}")&&!(F&&J.endsWith(":"+F)));U.length>0?(Q.style.display="block",U.forEach((J,G)=>{const[ee,Z]=J.split(":"),Y=document.createElement("div");Y.style.cssText="display: flex; gap: 6px; align-items: center;",Y.innerHTML=`
Click Cancel to deploy a new container.`)&&(e._useExisting=!0,e._existingContainer=W)}}catch{}s.textContent=`Deploy ${e.name}`;const d=e.subdomain||e.id.replace(/-/g,"");r.value=d;const w=document.getElementById("subpath-compat-warning");if(w)if(SITE.routingMode==="subdirectory"){const j=e.subpathSupport||"strip";j==="none"?(w.style.display="block",w.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+e.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):j==="strip"?(w.style.display="block",w.innerHTML='<span style="color: var(--muted);">&#9432; '+e.name+" has unverified subdirectory support. It may require additional configuration.</span>"):w.style.display="none"}else w.style.display="none";const T=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),$=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),P=document.querySelector(`input[name="dns-type"][value="${T}"]`),R=document.querySelector(`input[name="ssl-type"][value="${$}"]`);P?P.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,R?R.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,b.value=SITE.defaults.targetIP||"localhost",i.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),J=_?.querySelector("div");if(_&&J&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const j=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,q=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;j&&!j.dataset.moved&&(J.appendChild(j),j.dataset.moved="1"),q&&!q.dataset.moved&&(J.appendChild(q),q.dataset.moved="1")}const F=document.getElementById("media-path-section"),K=document.getElementById("deploy-media-path"),se=document.getElementById("media-path-description");if(e.mediaMount){F.style.display="block",K.value="",K.placeholder="/media/Movies, /media/TVShows or click Browse";const j=document.getElementById("detected-mounts-container"),q=document.getElementById("detected-mounts-list");try{const G=await(await fetch("/api/v1/media/detected-mounts")).json();if(G.success&&G.mounts.length>0){j.style.display="block",q.innerHTML="";const ee=[...new Set(G.mounts.map(Z=>Z.hostPath))];K.value=ee.join(", "),G.mounts.forEach(Z=>{const Y=document.createElement("button");Y.type="button";const le=ee.includes(Z.hostPath);Y.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Y.innerHTML=`<span style="font-weight: 500;">${escapeHtml(Z.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(Z.sourceImage)}</span>`,Y.title=`${Z.hostPath} (from ${Z.sourceContainer})`,Y.onclick=()=>{const re=K.value.split(",").map(de=>de.trim()).filter(de=>de),ce=re.indexOf(Z.hostPath);ce>=0?(re.splice(ce,1),Y.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(re.push(Z.hostPath),Y.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),K.value=re.join(", ")},q.appendChild(Y)})}else j.style.display="none"}catch{j.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(K)}}else F.style.display="none",K.value="",document.getElementById("detected-mounts-container").style.display="none";const V=document.getElementById("plex-claim-section");V&&(e.id==="plex"||e.claimToken?(V.style.display="block",document.getElementById("deploy-plex-claim").value=""):V.style.display="none");const Q=document.getElementById("volume-mounts-section"),te=document.getElementById("volume-mounts-list");if(te.innerHTML="",e.docker?.volumes?.length){const j=e.mediaMount?.containerPath,q=e.docker.volumes.filter(W=>!W.includes("{{MEDIA_PATH}}")&&!(j&&W.endsWith(":"+j)));q.length>0?(Q.style.display="block",q.forEach((W,G)=>{const[ee,Z]=W.split(":"),Y=document.createElement("div");Y.style.cssText="display: flex; gap: 6px; align-items: center;",Y.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${Z}" value="${ee}"
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 ${Z}</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>
`,te.appendChild(Y),Y.querySelector(".vol-browse-btn").onclick=()=>{const le=Y.querySelector(".vol-host-path");openFolderBrowser(le)}})):Q.style.display="none"}else Q.style.display="none";const ne=e.defaultPort||8080;o.value="",o.placeholder=`Default: ${ne}`;let X=document.getElementById("deploy-port-status");X||(X=document.createElement("div"),X.id="deploy-port-status",X.style.cssText="font-size: 0.8rem; margin-top: 4px;",o.parentNode.appendChild(X));async function oe(){const F=o.value||ne;X.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const U=await D(F);if(U.available)X.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(F))} is available</span>`;else{const J=await A(ne);X.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(F)} in use by ${escapeHtml(U.conflict?.usedBy||"unknown")}</span>
`;const G=document.createElement("button");G.type="button",G.textContent=`Use ${J}`,G.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",G.onclick=()=>{document.getElementById("deploy-port").value=J,X.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(J))}</span>`},X.appendChild(G)}}let ie;o.oninput=function(){clearTimeout(ie),ie=setTimeout(oe,500)},oe();try{const U=await(await fetch("/api/v1/tailscale/status")).json();U.success&&U.installed&&U.connected?u.innerHTML=`
`,te.appendChild(Y),Y.querySelector(".vol-browse-btn").onclick=()=>{const le=Y.querySelector(".vol-host-path");openFolderBrowser(le)}})):Q.style.display="none"}else Q.style.display="none";const ne=e.defaultPort||8080;o.value="",o.placeholder=`Default: ${ne}`;let X=document.getElementById("deploy-port-status");X||(X=document.createElement("div"),X.id="deploy-port-status",X.style.cssText="font-size: 0.8rem; margin-top: 4px;",o.parentNode.appendChild(X));async function oe(){const j=o.value||ne;X.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const q=await B(j);if(q.available)X.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(j))} is available</span>`;else{const W=await C(ne);X.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(j)} in use by ${escapeHtml(q.conflict?.usedBy||"unknown")}</span>
`;const G=document.createElement("button");G.type="button",G.textContent=`Use ${W}`,G.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",G.onclick=()=>{document.getElementById("deploy-port").value=W,X.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(W))}</span>`},X.appendChild(G)}}let ie;o.oninput=function(){clearTimeout(ie),ie=setTimeout(oe,500)},oe();try{const q=await(await fetch("/api/v1/tailscale/status")).json();q.success&&q.installed&&q.connected?v.innerHTML=`
<span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${U.self?.hostname} (${U.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${U.deviceCount} devices</span>
`:U.installed?u.innerHTML='<span style="color: #ff9800;">Not connected</span>':(u.innerHTML='<span style="color: var(--muted);">Not available</span>',s.disabled=!0)}catch{u.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const F=r.value||"subdomain",U=document.querySelector('input[name="dns-type"]:checked').value,J=document.querySelector('input[name="ssl-type"]:checked').value;let G="";if(SITE.routingMode==="subdirectory"&&SITE.domain)G=`https://${SITE.domain}/${F}`;else if(U==="private")G=`${J==="none"?"http":"https"}://${buildDomain(F)}`;else if(U==="public"){const ee=J==="none"?"http":"https",Z=SITE.domain||F;G=SITE.domain?`${ee}://${F}.${SITE.domain}`:`${ee}://${F}`}else{const ee=o.value||e.defaultPort||DC.DEFAULTS.SERVICE_PORT;G=`http://${v.value}:${ee}`}p.textContent=G}r.oninput=ae,v.oninput=ae,o.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(F=>{F.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(F=>{F.onchange=ae}),ae(),T.classList.remove("show"),t.classList.add("show"),t.dataset.appTemplate=JSON.stringify(e)}async function H(e){const t=e.appTemplate,i=safeGetJSON(y,[]),r=t._useExisting&&t._existingContainer,p=i.find(v=>v.id===e.subdomain);if(!(p&&!r&&!confirm(`An app with subdomain "${e.subdomain}" already exists. Redeploy?`))){if(p){const v=i.indexOf(p);i.splice(v,1),safeSet(y,JSON.stringify(i))}if(r)e.port=t._existingContainer.primaryPort;else{const v=e.port||t.defaultPort||8080;showNotification(`Checking port ${v} availability...`,"info",0);const o=await D(v);if(!o.available){const s=await A(t.defaultPort||8080);if(confirm(`Port ${v} is already in use by ${o.conflict?.usedBy||"another container"}.
<span style="color: var(--muted); margin-left: 8px;">${q.self?.hostname} (${q.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${q.deviceCount} devices</span>
`:q.installed?v.innerHTML='<span style="color: #ff9800;">Not connected</span>':(v.innerHTML='<span style="color: var(--muted);">Not available</span>',i.disabled=!0)}catch{v.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const j=r.value||"subdomain",q=document.querySelector('input[name="dns-type"]:checked').value,W=document.querySelector('input[name="ssl-type"]:checked').value;let G="";if(SITE.routingMode==="subdirectory"&&SITE.domain)G=`https://${SITE.domain}/${j}`;else if(q==="private")G=`${W==="none"?"http":"https"}://${buildDomain(j)}`;else if(q==="public"){const ee=W==="none"?"http":"https",Z=SITE.domain||j;G=SITE.domain?`${ee}://${j}.${SITE.domain}`:`${ee}://${j}`}else{const ee=o.value||e.defaultPort||DC.DEFAULTS.SERVICE_PORT;G=`http://${b.value}:${ee}`}p.textContent=G}r.oninput=ae,b.oninput=ae,o.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(j=>{j.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(j=>{j.onchange=ae}),ae(),k.classList.remove("show"),t.classList.add("show"),t.dataset.appTemplate=JSON.stringify(e)}async function y(e){const t=e.appTemplate,s=safeGetJSON(f,[]),r=t._useExisting&&t._existingContainer,p=s.find(b=>b.id===e.subdomain);if(!(p&&!r&&!confirm(`An app with subdomain "${e.subdomain}" already exists. Redeploy?`))){if(p){const b=s.indexOf(p);s.splice(b,1),safeSet(f,JSON.stringify(s))}if(r)e.port=t._existingContainer.primaryPort;else{const b=e.port||t.defaultPort||8080;showNotification(`Checking port ${b} availability...`,"info",0);const o=await B(b);if(!o.available){const i=await C(t.defaultPort||8080);if(confirm(`Port ${b} is already in use by ${o.conflict?.usedBy||"another container"}.
Would you like to use port ${s} instead?`))e.port=s;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(r?`Configuring ${t.name} with existing container...`:`Deploying ${t.name}...`,"info",0);try{const v={appId:t.id,config:{subdomain:e.subdomain,ip:e.ip,createDns:e.dnsType==="private",port:e.port||t.defaultPort||null,sslType:e.sslType,dnsType:e.dnsType,tailscaleOnly:e.tailscaleOnly||!1,mediaPath:e.mediaPath||null,plexClaimToken:e.plexClaimToken||null,customVolumes:e.customVolumes||null}};r&&(v.config.useExisting=!0,v.config.existingContainerId=t._existingContainer.id,v.config.existingPort=t._existingContainer.primaryPort,!e.port&&t._existingContainer.primaryPort&&(v.config.port=t._existingContainer.primaryPort));const s=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)})).json();if(s.success){const u={id:e.subdomain,name:t.name,logo:`/assets/${t.id}.png`,containerId:s.containerId,url:s.url,ip:e.ip,appTemplate:t.id,tailscaleOnly:e.tailscaleOnly||!1};i.push(u),safeSet(y,JSON.stringify(i)),window.APPS&&!window.APPS.some(g=>g.id===t.id)&&(window.APPS.push(u),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let l=s.usedExisting?`${t.name} configured with existing container!
URL: ${s.url}`:`${t.name} deployed successfully!
URL: ${s.url}`;s.warning&&(l+=`
Would you like to use port ${i} instead?`))e.port=i;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(r?`Configuring ${t.name} with existing container...`:`Deploying ${t.name}...`,"info",0);try{const b={appId:t.id,config:{subdomain:e.subdomain,ip:e.ip,createDns:e.dnsType==="private",port:e.port||t.defaultPort||null,sslType:e.sslType,dnsType:e.dnsType,tailscaleOnly:e.tailscaleOnly||!1,mediaPath:e.mediaPath||null,plexClaimToken:e.plexClaimToken||null,customVolumes:e.customVolumes||null}};r&&(b.config.useExisting=!0,b.config.existingContainerId=t._existingContainer.id,b.config.existingPort=t._existingContainer.primaryPort,!e.port&&t._existingContainer.primaryPort&&(b.config.port=t._existingContainer.primaryPort));const i=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)})).json();if(i.success){const v={id:e.subdomain,name:t.name,logo:`/assets/${t.id}.png`,containerId:i.containerId,url:i.url,ip:e.ip,appTemplate:t.id,tailscaleOnly:e.tailscaleOnly||!1};s.push(v),safeSet(f,JSON.stringify(s)),window.APPS&&!window.APPS.some(w=>w.id===t.id)&&(window.APPS.push(v),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let d=i.usedExisting?`${t.name} configured with existing container!
URL: ${i.url}`:`${t.name} deployed successfully!
URL: ${i.url}`;i.warning&&(d+=`
\u26A0 Warning: ${s.warning}`),showNotification(l,"success",8e3),delete t._useExisting,delete t._existingContainer,s.url&&s.url.startsWith("https://")&&x(s.url,t.name),s.setupInstructions&&s.setupInstructions.length>0&&setTimeout(()=>{const g=s.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${t.name}: ${g}`,"info",1e4)},1e3)}else throw new Error(s.error||"Deployment failed")}catch(v){console.error("Deployment error:",v),showNotification(`Failed to deploy ${t.name}: ${v.message}`,"error",8e3)}}}async function x(e,t){showNotification(`\u23F3 Generating SSL certificate for ${t}...`,"warning",6e4);let i=0;const r=12,p=async()=>{i++;try{const v=await fetch(e,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${t} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return i<r?setTimeout(p,5e3):showNotification(`\u26A0\uFE0F ${t} 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 O(){safeGetJSON(y,[]).forEach(t=>{window.APPS.some(i=>i.id===t.id)||window.APPS.push(t)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{$(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const z=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{z.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const e=JSON.parse(z.dataset.appTemplate),t=document.getElementById("deploy-media-path").value.trim(),i=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{i.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const r={appTemplate:e,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:t||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:i.length>0?i:null};if(!r.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(e.mediaMount?.required&&!t){showNotification("Please enter a media library path for this application","warning");return}z.classList.remove("show"),H(r)}),wireModal(z);const S=document.getElementById("folder-browser-modal"),E=document.getElementById("folder-browser-path"),k=document.getElementById("folder-browser-list"),b=document.getElementById("folder-browser-selected"),m=document.getElementById("folder-browser-selected-list");let f="",c=[],d=null;window.openFolderBrowser=function(e){d=e,c=e.value.split(",").map(t=>t.trim()).filter(t=>t),f="",n(),a(""),S.classList.add("show")};async function a(e){E.textContent=e||"Select a drive...",k.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const i=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(e)}`)).json();if(!i.success){k.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(i.error)}</div>`;return}f=i.path||"",E.textContent=f||"Select a drive...";let r="";i.parent&&i.parent!==i.path&&(r+=`<div class="folder-item" data-path="${escapeHtml(i.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
\u26A0 Warning: ${i.warning}`),showNotification(d,"success",8e3),delete t._useExisting,delete t._existingContainer,i.url&&i.url.startsWith("https://")&&x(i.url,t.name),i.setupInstructions&&i.setupInstructions.length>0&&setTimeout(()=>{const w=i.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${t.name}: ${w}`,"info",1e4)},1e3)}else throw new Error(i.error||"Deployment failed")}catch(b){console.error("Deployment error:",b),showNotification(`Failed to deploy ${t.name}: ${b.message}`,"error",8e3)}}}async function x(e,t){showNotification(`\u23F3 Generating SSL certificate for ${t}...`,"warning",6e4);let s=0;const r=12,p=async()=>{s++;try{const b=await fetch(e,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${t} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<r?setTimeout(p,5e3):showNotification(`\u26A0\uFE0F ${t} 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 O(){safeGetJSON(f,[]).forEach(t=>{window.APPS.some(s=>s.id===t.id)||window.APPS.push(t)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{m(),k.classList.add("show")}),wireModal(k,document.getElementById("app-selector-cancel"));const A=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{A.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const e=JSON.parse(A.dataset.appTemplate),t=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{s.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const r={appTemplate:e,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:t||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:s.length>0?s:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!r.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(e.mediaMount?.required&&!t){showNotification("Please enter a media library path for this application","warning");return}A.classList.remove("show"),y(r)}),wireModal(A);const N=document.getElementById("folder-browser-modal"),z=document.getElementById("folder-browser-path"),H=document.getElementById("folder-browser-list"),L=document.getElementById("folder-browser-selected"),g=document.getElementById("folder-browser-selected-list");let I="",c=[],l=null;window.openFolderBrowser=function(e){l=e,c=e.value.split(",").map(t=>t.trim()).filter(t=>t),I="",n(),a(""),N.classList.add("show")};async function a(e){z.textContent=e||"Select a drive...",H.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const s=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(e)}`)).json();if(!s.success){H.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}I=s.path||"",z.textContent=I||"Select a drive...";let r="";s.parent&&s.parent!==s.path&&(r+=`<div class="folder-item" data-path="${escapeHtml(s.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>`),i.items.length===0&&!i.parent?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':i.items.length===0?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':i.items.forEach(p=>{const v=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",o=c.includes(p.path),s=o?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";r+=`<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;">${v}</span>
</div>`),s.items.length===0&&!s.parent?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':s.items.length===0?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(p=>{const b=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",o=c.includes(p.path),i=o?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";r+=`<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; ${i}">
<span style="font-size: 1.2rem;">${b}</span>
<span style="flex: 1;">${escapeHtml(p.name)}</span>
${o?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),k.innerHTML=r,k.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{a(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const v=c.includes(p.dataset.path);p.style.background=v?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(t){k.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(t.message)}</div>`}}function n(){if(c.length===0){b.style.display="none";return}b.style.display="block",m.innerHTML=c.map(e=>`
</div>`}),H.innerHTML=r,H.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{a(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const b=c.includes(p.dataset.path);p.style.background=b?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(t){H.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(t.message)}</div>`}}function n(){if(c.length===0){L.style.display="none";return}L.style.display="block",g.innerHTML=c.map(e=>`
<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(e)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(e)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span>
`).join("")}window.removeSelectedFolder=function(e){c=c.filter(t=>t!==e),n(),a(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!c.includes(f)&&(c.push(f),n(),a(f))}),wireModal(S,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{d&&(d.value=c.join(", ")),S.classList.remove("show")}),O()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
`).join("")}window.removeSelectedFolder=function(e){c=c.filter(t=>t!==e),n(),a(I)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{I&&!c.includes(I)&&(c.push(I),n(),a(I))}),wireModal(N,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{l&&(l.value=c.join(", ")),N.classList.remove("show")}),O()})(),(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>
@@ -429,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 y=null,h=null,L=null,T=1,B=!1;const N=document.getElementById("recipe-deploy-modal"),D=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),$=document.getElementById("recipe-next");wireModal(N,D);async function P(){try{const c=await fetch("/api/v1/recipes/templates"),d=await c.json();if(d.success)return y=d.templates,h=d.categories,!0;if(c.status===403)return B=!1,!1}catch(c){console.warn("Failed to fetch recipe templates:",c.message)}return!1}async function C(){try{B=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{B=!1}return B}window.renderRecipeCards=async function(c){await C();let d;if(B&&y?d=y:d=H(),!d||d.length===0)return;const a=document.createElement("div");a.className="app-category-header",a.innerHTML="\u{1F9EA} Recipes",a.style.borderBottomColor="#8e44ad",c.appendChild(a);const n=Array.isArray(d)?d:Object.values(d);n.sort((e,t)=>(t.popularity||0)-(e.popularity||0));for(const e of n){const t=document.createElement("div");t.className="app-option",t.style.position="relative";const i=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${e.componentCount||e.components?.length||"?"} apps</div>`,r=B?"":'<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>';t.innerHTML=`
</div>`);let f=null,E=null,M=null,k=1,S=!1;const D=document.getElementById("recipe-deploy-modal"),B=document.getElementById("recipe-cancel"),C=document.getElementById("recipe-prev"),m=document.getElementById("recipe-next");wireModal(D,B);async function h(){try{const c=await fetch("/api/v1/recipes/templates"),l=await c.json();if(l.success)return f=l.templates,E=l.categories,!0;if(c.status===403)return S=!1,!1}catch(c){console.warn("Failed to fetch recipe templates:",c.message)}return!1}async function u(){try{S=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{S=!1}return S}window.renderRecipeCards=async function(c){await u();let l;if(S&&f?l=f:l=y(),!l||l.length===0)return;const a=document.createElement("div");a.className="app-category-header",a.innerHTML="\u{1F9EA} Recipes",a.style.borderBottomColor="#8e44ad",c.appendChild(a);const n=Array.isArray(l)?l:Object.values(l);n.sort((e,t)=>(t.popularity||0)-(e.popularity||0));for(const e of n){const t=document.createElement("div");t.className="app-option",t.style.position="relative";const s=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${e.componentCount||e.components?.length||"?"} apps</div>`,r=S?"":'<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>';t.innerHTML=`
${r}
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${i}
`,t.onclick=()=>{if(!B){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(e)},c.appendChild(t)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function x(c){L=c,T=1;const d=document.getElementById("app-selector-modal");d&&d.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${c.name}`,O(),z(),N.classList.add("show")}function O(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(c=>{const d=parseInt(c.dataset.step);c.classList.toggle("active",d===T),c.classList.toggle("completed",d<T)});for(let c=1;c<=4;c++){const d=document.getElementById(`recipe-step-${c}`);d&&(d.style.display=c===T?"":"none")}A.style.display=T>1&&T<4?"":"none",T===4?($.style.display="none",D.textContent="Close"):T===3?($.textContent="\u{1F680} Deploy",$.style.display="",D.textContent="Cancel"):($.textContent="Next",$.style.display="",D.textContent="Cancel")}function z(){const c=document.getElementById("recipe-component-list");c.innerHTML="";const d=L.components||[];for(const a of d){const n=document.createElement("div");n.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const e=a.required,t=a.internal;n.innerHTML=`
${s}
`,t.onclick=()=>{if(!S){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(e)},c.appendChild(t)}};function y(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function x(c){M=c,k=1;const l=document.getElementById("app-selector-modal");l&&l.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${c.name}`,O(),A(),D.classList.add("show")}function O(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(c=>{const l=parseInt(c.dataset.step);c.classList.toggle("active",l===k),c.classList.toggle("completed",l<k)});for(let c=1;c<=4;c++){const l=document.getElementById(`recipe-step-${c}`);l&&(l.style.display=c===k?"":"none")}C.style.display=k>1&&k<4?"":"none",k===4?(m.style.display="none",B.textContent="Close"):k===3?(m.textContent="\u{1F680} Deploy",m.style.display="",B.textContent="Cancel"):(m.textContent="Next",m.style.display="",B.textContent="Cancel")}function A(){const c=document.getElementById("recipe-component-list");c.innerHTML="";const l=M.components||[];for(const a of l){const n=document.createElement("div");n.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const e=a.required,t=a.internal;n.innerHTML=`
<input type="checkbox" ${e?"checked disabled":"checked"} data-component-id="${a.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;">
@@ -447,20 +463,20 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>
${a.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(a.note)}</div>`:""}
</div>
`,c.appendChild(n)}}function S(){const c=document.getElementById("recipe-volumes-section"),d=document.getElementById("recipe-volume-list"),a=L.sharedVolumes;if(a&&Object.keys(a).length>0){c.style.display="",d.innerHTML="";for(const[n,e]of Object.entries(a)){const t=document.createElement("div");t.style.cssText="display: grid; gap: 4px;",t.innerHTML=`
`,c.appendChild(n)}}function N(){const c=document.getElementById("recipe-volumes-section"),l=document.getElementById("recipe-volume-list"),a=M.sharedVolumes;if(a&&Object.keys(a).length>0){c.style.display="",l.innerHTML="";for(const[n,e]of Object.entries(a)){const t=document.createElement("div");t.style.cssText="display: grid; gap: 4px;",t.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(e.label||n)}</label>
<input type="text" data-volume-key="${n}" value="${escapeHtml(e.defaultPath||"")}"
placeholder="${escapeHtml(e.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(e.description||"")}</div>
`,d.appendChild(t)}}else c.style.display="none"}function E(){const c=document.getElementById("recipe-review-content"),d=k(),a=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),n={};a.forEach(r=>{n[r.dataset.volumeKey]=r.value});const e=document.getElementById("recipe-timezone").value||"UTC",t=document.getElementById("recipe-ip").value||"host.docker.internal",i=document.getElementById("recipe-tailscale").checked;c.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(L.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(L.description||"")}</div>
`,l.appendChild(t)}}else c.style.display="none"}function z(){const c=document.getElementById("recipe-review-content"),l=H(),a=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),n={};a.forEach(r=>{n[r.dataset.volumeKey]=r.value});const e=document.getElementById("recipe-timezone").value||"UTC",t=document.getElementById("recipe-ip").value||"host.docker.internal",s=document.getElementById("recipe-tailscale").checked;c.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(M.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(M.description||"")}</div>
<div style="margin-bottom: 12px;">
<strong>Components (${d.length}):</strong>
<strong>Components (${l.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;">
${d.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
${l.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")}
</div>
@@ -472,15 +488,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(e)} &bull; IP: ${escapeHtml(t)} ${i?"&bull; Tailscale only":""}
Timezone: ${escapeHtml(e)} &bull; IP: ${escapeHtml(t)} ${s?"&bull; Tailscale only":""}
</div>
${L.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(L.network.name)}</code></div>`:""}
`}function k(){const c=document.querySelectorAll("#recipe-component-list input[data-component-id]"),d=new Set;c.forEach(n=>{n.checked&&d.add(n.dataset.componentId)});const a=L.components||[];return a.filter(n=>n.required).forEach(n=>d.add(n.id)),a.filter(n=>d.has(n.id))}async function b(){const c=document.getElementById("recipe-progress-list"),d=document.getElementById("recipe-deploy-result");d.style.display="none",c.innerHTML="";const a=k();for(const i of a){const r=document.createElement("div");r.id=`recipe-progress-${i.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
${M.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(M.network.name)}</code></div>`:""}
`}function H(){const c=document.querySelectorAll("#recipe-component-list input[data-component-id]"),l=new Set;c.forEach(n=>{n.checked&&l.add(n.dataset.componentId)});const a=M.components||[];return a.filter(n=>n.required).forEach(n=>l.add(n.id)),a.filter(n=>l.has(n.id))}async function L(){const c=document.getElementById("recipe-progress-list"),l=document.getElementById("recipe-deploy-result");l.style.display="none",c.innerHTML="";const a=H();for(const s of a){const r=document.createElement("div");r.id=`recipe-progress-${s.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(i.role||i.id)}</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(s.role||s.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,c.appendChild(r)}const n=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),e={};n.forEach(i=>{e[i.dataset.volumeKey]=i.value});const t={selectedComponents:a.map(i=>i.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:e},componentOverrides:{}};for(const i of a)m(i.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:L.id,config:t})})).json();if(r.success){for(const p of r.deployed||[])m(p.id,"success",p.url?`Running \u2192 ${p.url}`:"Running");for(const p of r.errors||[])m(p.componentId,"error",p.error);d.style.display="",d.innerHTML=`
`,c.appendChild(r)}const n=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),e={};n.forEach(s=>{e[s.dataset.volumeKey]=s.value});const t={selectedComponents:a.map(s=>s.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:e},componentOverrides:{}};for(const s of a)g(s.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:M.id,config:t})})).json();if(r.success){for(const p of r.deployed||[])g(p.id,"success",p.url?`Running \u2192 ${p.url}`:"Running");for(const p of r.errors||[])g(p.componentId,"error",p.error);l.style.display="",l.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(r.message||"Deployed!")}</div>
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
@@ -488,11 +504,11 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(p=>`<li>${escapeHtml(p)}</li>`).join("")}</ul>
</div>`:""}
</div>
`,showNotification(`${L.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else d.style.display="",d.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(`${M.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else l.style.display="",l.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(r.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(i){d.style.display="",d.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(i.message)}
</div>`}}function m(c,d,a){const n=document.getElementById(`recipe-progress-${c}`);if(!n)return;const e=n.querySelector(".recipe-progress-icon"),t=n.querySelector(".recipe-progress-status");d==="deploying"?(e.textContent="\u23F3",t.style.color="var(--accent)"):d==="success"?(e.textContent="\u2705",t.style.color="var(--ok-fg)"):d==="error"&&(e.textContent="\u274C",t.style.color="var(--bad-fg)"),t.textContent=a}$.addEventListener("click",()=>{if(T===3){T=4,O(),b();return}T<3&&(T++,O(),T===2&&S(),T===3&&E())}),A.addEventListener("click",()=>{T>1&&T<4&&(T--,O())}),window.groupRecipeCards=function(){const c=document.querySelectorAll(".service-card[data-recipe-id]");if(c.length===0)return;const d={};c.forEach(a=>{const n=a.dataset.recipeId;d[n]||(d[n]=[]),d[n].push(a)});for(const[a,n]of Object.entries(d))n.length<2||n.forEach((e,t)=>{if(e.style.borderLeft="3px solid rgba(142,68,173,0.5)",t===0){let i=e.querySelector(".recipe-group-label");i||(i=document.createElement("div"),i.className="recipe-group-label",i.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;",i.textContent=a.replace(/-/g," "),e.style.position="relative",e.appendChild(i))}})},window.manageRecipe=async function(c,d){const a=`/api/v1/recipes/${c}/${d}`,n=d==="remove"?"DELETE":"POST",e=d==="remove"?`/api/v1/recipes/${c}`:a;if(!(d==="remove"&&!confirm(`Remove the entire ${c} recipe? This will delete all containers and configuration.`)))try{const i=await(await secureFetch(e,{method:n})).json();i.success?(showNotification(`Recipe ${d}: ${i.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${d} failed: ${i.error}`,"error",5e3)}catch(t){showNotification(`Network error: ${t.message}`,"error",5e3)}};const f=document.createElement("style");f.textContent=`
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(s){l.style.display="",l.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(s.message)}
</div>`}}function g(c,l,a){const n=document.getElementById(`recipe-progress-${c}`);if(!n)return;const e=n.querySelector(".recipe-progress-icon"),t=n.querySelector(".recipe-progress-status");l==="deploying"?(e.textContent="\u23F3",t.style.color="var(--accent)"):l==="success"?(e.textContent="\u2705",t.style.color="var(--ok-fg)"):l==="error"&&(e.textContent="\u274C",t.style.color="var(--bad-fg)"),t.textContent=a}m.addEventListener("click",()=>{if(k===3){k=4,O(),L();return}k<3&&(k++,O(),k===2&&N(),k===3&&z())}),C.addEventListener("click",()=>{k>1&&k<4&&(k--,O())}),window.groupRecipeCards=function(){const c=document.querySelectorAll(".service-card[data-recipe-id]");if(c.length===0)return;const l={};c.forEach(a=>{const n=a.dataset.recipeId;l[n]||(l[n]=[]),l[n].push(a)});for(const[a,n]of Object.entries(l))n.length<2||n.forEach((e,t)=>{if(e.style.borderLeft="3px solid rgba(142,68,173,0.5)",t===0){let s=e.querySelector(".recipe-group-label");s||(s=document.createElement("div"),s.className="recipe-group-label",s.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;",s.textContent=a.replace(/-/g," "),e.style.position="relative",e.appendChild(s))}})},window.manageRecipe=async function(c,l){const a=`/api/v1/recipes/${c}/${l}`,n=l==="remove"?"DELETE":"POST",e=l==="remove"?`/api/v1/recipes/${c}`:a;if(!(l==="remove"&&!confirm(`Remove the entire ${c} recipe? This will delete all containers and configuration.`)))try{const s=await(await secureFetch(e,{method:n})).json();s.success?(showNotification(`Recipe ${l}: ${s.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${l} failed: ${s.error}`,"error",5e3)}catch(t){showNotification(`Network error: ${t.message}`,"error",5e3)}};const I=document.createElement("style");I.textContent=`
.recipe-step {
flex: 1;
text-align: center;
@@ -534,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel {
min-height: 180px;
}
`,document.head.appendChild(f),C()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const y=document.getElementById("reload-caddy-top"),h=y.textContent;try{y.textContent="\u23F3 Reloading...",y.disabled=!0;const L=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),T=await L.json();if(L.ok&&T.success)y.textContent="\u2705 Reloaded!",setTimeout(()=>{y.textContent=h,y.disabled=!1},2e3);else throw new Error(T.error||"Reload failed")}catch(L){y.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${L.message}`,"error"),setTimeout(()=>{y.textContent=h,y.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const y=document.getElementById("error-log-modal"),h=document.getElementById("error-log-content"),L=document.getElementById("view-error-logs"),T=document.getElementById("error-log-refresh"),B=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close");async function D(){h.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const P=await(await fetch("/api/v1/error-logs")).json();P.success&&P.logs?P.logs.length===0?h.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':h.innerHTML=P.logs.map(C=>`
`,document.head.appendChild(I),u()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const f=document.getElementById("reload-caddy-top"),E=f.textContent;try{f.textContent="\u23F3 Reloading...",f.disabled=!0;const M=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),k=await M.json();if(M.ok&&k.success)f.textContent="\u2705 Reloaded!",setTimeout(()=>{f.textContent=E,f.disabled=!1},2e3);else throw new Error(k.error||"Reload failed")}catch(M){f.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${M.message}`,"error"),setTimeout(()=>{f.textContent=E,f.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const f=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),M=document.getElementById("view-error-logs"),k=document.getElementById("error-log-refresh"),S=document.getElementById("error-log-clear"),D=document.getElementById("error-log-close");async function B(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const h=await(await fetch("/api/v1/error-logs")).json();h.success&&h.logs?h.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=h.logs.map(u=>`
<div class="log-entry error">
<span class="log-timestamp">${new Date(C.timestamp).toLocaleString()}</span>
<span class="log-timestamp">${new Date(u.timestamp).toLocaleString()}</span>
<span class="log-level">ERROR</span>
<div class="log-message">
<strong>${escapeHtml(C.context)}</strong>: ${escapeHtml(C.error)}
${C.details?`<br><small style="opacity: 0.7;">${escapeHtml(C.details)}</small>`:""}
<strong>${escapeHtml(u.context)}</strong>: ${escapeHtml(u.error)}
${u.details?`<br><small style="opacity: 0.7;">${escapeHtml(u.details)}</small>`:""}
</div>
</div>
`).join(""):h.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch($){h.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml($.message)}</div>`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),D()):showNotification("\u274C Failed to clear logs","error",3e3)}catch($){showNotification(`\u274C Error: ${$.message}`,"error",3e3)}}L?.addEventListener("click",()=>{y.classList.add("show"),D()}),T?.addEventListener("click",D),B?.addEventListener("click",A),wireModal(y,N)})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(m){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(m.message)}</div>`}}async function C(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),B()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(m){showNotification(`\u274C Error: ${m.message}`,"error",3e3)}}M?.addEventListener("click",()=>{f.classList.add("show"),B()}),k?.addEventListener("click",B),S?.addEventListener("click",C),wireModal(f,D)})(),(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;">
@@ -622,73 +638,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button>
</div>
</div>
</div>`);const y=document.getElementById("arr-setup-modal"),h=document.getElementById("arr-setup-btn"),L=document.getElementById("arr-setup-cancel"),T=document.getElementById("smart-connect-btn"),B=document.getElementById("smart-phase-detect"),N=document.getElementById("smart-phase-credentials"),D=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),$=document.getElementById("smart-detect-results"),P=document.getElementById("smart-credential-inputs"),C=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),O=document.getElementById("smart-retry-btn");let z=null;const S={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},E={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function k(n){B.style.display=n==="detect"?"block":"none",N.style.display=n==="credentials"?"block":"none",D.style.display=n==="progress"?"block":"none",A.style.display=n==="results"?"block":"none"}function b(n){const e={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"}},t=e[n]||e.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, ${t.bg} 20%, transparent); color: ${t.bg};">${t.icon} ${t.text}</span>`}async function m(){k("detect"),$.style.display="none";try{if(z=await(await fetch("/api/v1/arr/smart-detect")).json(),!z.success){$.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(z.error)}</div>`,$.style.display="block";return}let e='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[i,r]of Object.entries(z.services)){const p=S[i]||"\u{1F4E6}",v=E[i]||i,o=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",s=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",u=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";e+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
</div>`);const f=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),M=document.getElementById("arr-setup-cancel"),k=document.getElementById("smart-connect-btn"),S=document.getElementById("smart-phase-detect"),D=document.getElementById("smart-phase-credentials"),B=document.getElementById("smart-phase-progress"),C=document.getElementById("smart-phase-results"),m=document.getElementById("smart-detect-results"),h=document.getElementById("smart-credential-inputs"),u=document.getElementById("smart-progress-steps"),y=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),O=document.getElementById("smart-retry-btn");let A=null;const N={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},z={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function H(n){S.style.display=n==="detect"?"block":"none",D.style.display=n==="credentials"?"block":"none",B.style.display=n==="progress"?"block":"none",C.style.display=n==="results"?"block":"none"}function L(n){const e={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"}},t=e[n]||e.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, ${t.bg} 20%, transparent); color: ${t.bg};">${t.icon} ${t.text}</span>`}async function g(){H("detect"),m.style.display="none";try{if(A=await(await fetch("/api/v1/arr/smart-detect")).json(),!A.success){m.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(A.error)}</div>`,m.style.display="block";return}let e='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[s,r]of Object.entries(A.services)){const p=N[s]||"\u{1F4E6}",b=z[s]||s,o=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",i=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",v=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";e+=`<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;">${p}</span>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${v}</div>
<div style="font-weight: 500; font-size: 0.9rem;">${b}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${o} ${s} ${u}
${o} ${i} ${v}
</div>
</div>
${b(r.status)}
</div>`}e+="</div>";const t=z.summary;e+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${L(r.status)}
</div>`}e+="</div>";const t=A.summary;e+=`<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(t.fullyConnected))}/${escapeHtml(String(t.totalDetected+(5-t.totalDetected)))} services detected &middot;
${escapeHtml(String(t.fullyConnected))} connected${t.needsApiKey>0?` &middot; <strong>${escapeHtml(String(t.needsApiKey))} needs API key</strong>`:""}
</div>`,$.innerHTML=e,$.style.display="block",f(z),setTimeout(()=>{k("credentials")},800)}catch(n){$.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(n.message)}</div>`,$.style.display="block"}}function f(n){let e="";const t=n.services,i=["radarr","sonarr","prowlarr"];for(const v of i){const o=t[v];if(!o||o.status==="not_found"&&!o.url)continue;const s=S[v],u=E[v],l=o.status==="connected";e+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${l?"var(--ok-fg)":"var(--border)"};">
</div>`,m.innerHTML=e,m.style.display="block",I(A),setTimeout(()=>{H("credentials")},800)}catch(n){m.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(n.message)}</div>`,m.style.display="block"}}function I(n){let e="";const t=n.services,s=["radarr","sonarr","prowlarr"];for(const b of s){const o=t[b];if(!o||o.status==="not_found"&&!o.url)continue;const i=N[b],v=z[b],d=o.status==="connected";e+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${d?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${s}</span>
<span style="font-weight: 500;">${u}</span>
<span id="smart-${v}-status" style="margin-left: auto; font-size: 0.75rem;">
${l?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
<span style="font-size: 1.1rem;">${i}</span>
<span style="font-weight: 500;">${v}</span>
<span id="smart-${b}-status" style="margin-left: auto; font-size: 0.75rem;">
${d?'<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-${v}-url" value="${escapeHtml(o.url||"")}" placeholder="https://seedbox.com/${v}/"
<input type="text" id="smart-${b}-url" value="${escapeHtml(o.url||"")}" placeholder="https://seedbox.com/${b}/"
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-${v}-key" placeholder="${l?"(saved)":"Paste API key"}"
<input type="password" id="smart-${b}-key" placeholder="${d?"(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('${v}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const r=t.plex;if(r){const v=r.status==="connected";e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<button onclick="smartTestConnection('${b}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const r=t.plex;if(r){const b=r.status==="connected";e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${b?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span>
${b(r.status)}
${L(r.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
</div>
</div>`}const p=t.seerr;if(p){const v=p.status==="connected";let o="";if(p.configuredServices){const s=p.configuredServices;o=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${s.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${s.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${s.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"};">
</div>`}const p=t.seerr;if(p){const b=p.status==="connected";let o="";if(p.configuredServices){const i=p.configuredServices;o=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${i.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${i.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${i.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${b?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span>
${b(p.status)}
${L(p.status)}
</div>
${o}
</div>`}P.innerHTML=e}window.smartTestConnection=async function(n){const e=document.getElementById(`smart-${n}-url`),t=document.getElementById(`smart-${n}-key`),i=document.getElementById(`smart-${n}-status`),r=e?.value.trim(),p=t?.value.trim();if(!r||!p){i.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}i.innerHTML='<span class="brand-spinner"></span>';try{const o=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:n,url:r,apiKey:p})})).json();o.success?i.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(o.appName||"Connected")} v${escapeHtml(o.version||"")}</span>`:i.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(o.error)}</span>`}catch(v){i.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(v.message)}</span>`}};async function c(){k("progress"),C.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 n={};for(const t of["radarr","sonarr","prowlarr"]){const i=document.getElementById(`smart-${t}-url`)?.value.trim(),r=document.getElementById(`smart-${t}-key`)?.value.trim();r&&i?n[t]={apiKey:r,url:i}:r&&(n[t]={apiKey:r})}const e={services:Object.keys(n).length>0?n: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 i=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();let r="";for(const p of i.steps||[]){const v=p.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',o=p.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${v}
</div>`}h.innerHTML=e}window.smartTestConnection=async function(n){const e=document.getElementById(`smart-${n}-url`),t=document.getElementById(`smart-${n}-key`),s=document.getElementById(`smart-${n}-status`),r=e?.value.trim(),p=t?.value.trim();if(!r||!p){s.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}s.innerHTML='<span class="brand-spinner"></span>';try{const o=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:n,url:r,apiKey:p})})).json();o.success?s.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(o.appName||"Connected")} v${escapeHtml(o.version||"")}</span>`:s.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(o.error)}</span>`}catch(b){s.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(b.message)}</span>`}};async function c(){H("progress"),u.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 n={};for(const t of["radarr","sonarr","prowlarr"]){const s=document.getElementById(`smart-${t}-url`)?.value.trim(),r=document.getElementById(`smart-${t}-key`)?.value.trim();r&&s?n[t]={apiKey:r,url:s}:r&&(n[t]={apiKey:r})}const e={services:Object.keys(n).length>0?n: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 s=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();let r="";for(const p of s.steps||[]){const b=p.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',o=p.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${b}
<span>${escapeHtml(p.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${o};">${escapeHtml(p.details||"")}</span>
</div>`}C.innerHTML=r,setTimeout(()=>d(i),500)}catch(t){C.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(t.message)}</div>`}}function d(n){k("results");const e=n.summary||{},t=e.failed===0&&e.succeeded>0,i=t?"var(--ok-fg)":"#f39c12",r=t?"&#10003;":"&#9888;",p=t?"All Connected!":`${escapeHtml(String(e.succeeded))}/${escapeHtml(String(e.totalSteps))} Steps Succeeded`;let v=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${i} 12%, transparent); border-radius: 10px; border: 1px solid ${i}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${i};">${r}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${i};">${p}</div>
</div>`}u.innerHTML=r,setTimeout(()=>l(s),500)}catch(t){u.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(t.message)}</div>`}}function l(n){H("results");const e=n.summary||{},t=e.failed===0&&e.succeeded>0,s=t?"var(--ok-fg)":"#f39c12",r=t?"&#10003;":"&#9888;",p=t?"All Connected!":`${escapeHtml(String(e.succeeded))}/${escapeHtml(String(e.totalSteps))} Steps Succeeded`;let b=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${s} 12%, transparent); border-radius: 10px; border: 1px solid ${s}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${s};">${r}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${s};">${p}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(e.succeeded))} succeeded, ${escapeHtml(String(e.failed))} failed</div>
</div>`;v+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const o of n.steps||[]){const s=o.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';v+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${s} ${escapeHtml(o.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(o.details||"")}</span>
</div>`}v+="</div>",H.innerHTML=v,O.style.display=e.failed>0?"block":"none",n.steps?.some(o=>o.step.includes("Plex")&&o.status==="success")&&a()}async function a(){try{const e=await(await fetch("/api/v1/plex/libraries")).json();if(e.success&&e.libraries?.length>0){let t=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
</div>`;b+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const o of n.steps||[]){const i=o.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';b+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${i} ${escapeHtml(o.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(o.details||"")}</span>
</div>`}b+="</div>",y.innerHTML=b,O.style.display=e.failed>0?"block":"none",n.steps?.some(o=>o.step.includes("Plex")&&o.status==="success")&&a()}async function a(){try{const e=await(await fetch("/api/v1/plex/libraries")).json();if(e.success&&e.libraries?.length>0){let t=`<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(e.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const i of e.libraries){const r=i.type==="movie"?"\u{1F3AC}":i.type==="show"?"\u{1F4FA}":"\u{1F3B5}";t+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${r} <strong>${escapeHtml(i.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(i.count))} items</span>
</div>`}t+="</div></div>",x.innerHTML=t,x.style.display="block"}}catch{}}h?.addEventListener("click",()=>{y.classList.add("show"),x.style.display="none",m()}),wireModal(y,L),T?.addEventListener("click",c),O?.addEventListener("click",c)})(),(function(){injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const s of e.libraries){const r=s.type==="movie"?"\u{1F3AC}":s.type==="show"?"\u{1F4FA}":"\u{1F3B5}";t+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${r} <strong>${escapeHtml(s.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(s.count))} items</span>
</div>`}t+="</div></div>",x.innerHTML=t,x.style.display="block"}}catch{}}E?.addEventListener("click",()=>{f.classList.add("show"),x.style.display="none",g()}),wireModal(f,M),k?.addEventListener("click",c),O?.addEventListener("click",c)})(),(function(){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>
@@ -767,6 +783,52 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>
</div>
<!-- Email -->
<div class="notification-provider provider-card">
<div class="provider-header">
<label class="checkbox-label">
<input type="checkbox" id="email-enabled" />
<span class="fw-500">Email (SMTP)</span>
</label>
<button id="email-test" class="test-btn btn-xs">Test</button>
</div>
<div id="email-config" style="display: none;">
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">
<div>
<label class="field-label-sm">SMTP Host:</label>
<input type="text" id="email-host" placeholder="smtp.gmail.com" />
</div>
<div>
<label class="field-label-sm">Port:</label>
<input type="number" id="email-port" value="587" placeholder="587" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
<div>
<label class="field-label-sm">Username:</label>
<input type="text" id="email-user" placeholder="user@gmail.com" />
</div>
<div>
<label class="field-label-sm">Password:</label>
<input type="password" id="email-pass" placeholder="app password" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div>
<label class="field-label-sm">From:</label>
<input type="text" id="email-from" placeholder="DashCaddy &lt;noreply@example.com&gt;" />
</div>
<div>
<label class="field-label-sm">To:</label>
<input type="text" id="email-to" placeholder="admin@example.com" />
</div>
</div>
<label style="display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 0.8rem;">
<input type="checkbox" id="email-secure" /> Use TLS (port 465)
</label>
</div>
</div>
<!-- Health Check Settings -->
<h4 class="section-heading">Health Monitoring</h4>
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
@@ -822,15 +884,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 y=document.getElementById("notifications-modal"),h=document.getElementById("manage-notifications"),L=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy"].forEach(C=>{const H=document.getElementById(`${C}-enabled`),x=document.getElementById(`${C}-config`);H?.addEventListener("change",()=>{x.style.display=H.checked?"block":"none"})});const B=document.getElementById("health-check-enabled"),N=document.getElementById("health-check-config");B?.addEventListener("change",()=>{N.style.opacity=B.checked?"1":"0.5"});async function D(){try{const H=await(await fetch("/api/v1/notifications/config")).json();if(H.success){const x=H.config;document.getElementById("notifications-enabled").checked=x.enabled,document.getElementById("discord-enabled").checked=x.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=x.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=x.providers?.ntfy?.enabled||!1,document.getElementById("discord-config").style.display=x.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=x.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=x.providers?.ntfy?.enabled?"block":"none",x.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=x.providers.ntfy.serverUrl),document.getElementById("health-check-enabled").checked=x.healthCheck?.enabled||!1,x.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=x.healthCheck.intervalMinutes),x.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=x.events?.containerDown!==!1,document.getElementById("event-container-up").checked=x.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=x.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=x.events?.deploymentFailed!==!1}}catch(C){console.error("Failed to load notification config:",C)}}async function A(){try{const H=await(await fetch("/api/v1/notifications/history?limit=10")).json(),x=document.getElementById("notification-history");H.success&&H.history?.length>0?x.innerHTML=H.history.map(O=>{const z=new Date(O.timestamp).toLocaleString();return`
</div>`);const f=document.getElementById("notifications-modal"),E=document.getElementById("manage-notifications"),M=document.getElementById("notifications-save"),k=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(u=>{const y=document.getElementById(`${u}-enabled`),x=document.getElementById(`${u}-config`);y?.addEventListener("change",()=>{x.style.display=y.checked?"block":"none"})});const S=document.getElementById("health-check-enabled"),D=document.getElementById("health-check-config");S?.addEventListener("change",()=>{D.style.opacity=S.checked?"1":"0.5"});async function B(){try{const y=await(await fetch("/api/v1/notifications/config")).json();if(y.success){const x=y.config;document.getElementById("notifications-enabled").checked=x.enabled,document.getElementById("discord-enabled").checked=x.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=x.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=x.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=x.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=x.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=x.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=x.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=x.providers?.email?.enabled?"block":"none",x.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=x.providers.ntfy.serverUrl),x.providers?.email?.host&&(document.getElementById("email-host").value=x.providers.email.host),x.providers?.email?.from&&(document.getElementById("email-from").value=x.providers.email.from),document.getElementById("health-check-enabled").checked=x.healthCheck?.enabled||!1,x.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=x.healthCheck.intervalMinutes),x.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=x.events?.containerDown!==!1,document.getElementById("event-container-up").checked=x.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=x.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=x.events?.deploymentFailed!==!1}}catch(u){console.error("Failed to load notification config:",u)}}async function C(){try{const y=await(await fetch("/api/v1/notifications/history?limit=10")).json(),x=document.getElementById("notification-history");y.success&&y.history?.length>0?x.innerHTML=y.history.map(O=>{const A=new Date(O.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)"}[O.type]||"var(--muted)"}">${O.type==="success"?"\u2713":O.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(O.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${z}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${A}</div>
</div>
</div>
`}).join(""):x.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(C){console.error("Failed to load notification history:",C)}}async function $(){try{const C={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()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},x=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(C)})).json();x.success?(showNotification("Notification settings saved","success",3e3),y.classList.remove("show")):showNotification(`Failed to save: ${x.error}`,"error",3e3)}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}}async function P(C){try{const x=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:C})})).json();x.success?showNotification(`Test ${C} notification sent!`,"success",3e3):showNotification(`Test failed: ${x.error}`,"error",3e3)}catch(H){showNotification(`Error: ${H.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>P("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>P("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>P("ntfy")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const H=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();H.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(H.lastCheck).toLocaleString()} (${H.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}}),h?.addEventListener("click",()=>{y.classList.add("show"),D(),A()}),L?.addEventListener("click",$),wireModal(y,T)})(),(function(){document.addEventListener("click",y=>{const h=y.target.closest(".panel-tab");if(!h)return;const L=h.dataset.panel;if(!L)return;const T=h.closest(".panel-tabs"),B=T.closest(".weather-modal-content");T.querySelectorAll(".panel-tab").forEach(D=>D.classList.remove("active")),h.classList.add("active"),B.querySelectorAll(".panel-section").forEach(D=>D.classList.remove("active"));const N=B.querySelector("#"+L);N&&N.classList.add("active")})})(),(function(){var y=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function h(){for(var a={},n=0;n<y.length;n++){var e=y[n],t=safeGet(e);t!=null&&(a[e]=t)}try{for(var i=0;i<localStorage.length;i++){var r=localStorage.key(i);/^widget-.+-enabled$/.test(r)&&(a[r]=localStorage.getItem(r))}}catch{}return a}function L(a){if(!a||typeof a!="object")return 0;var n=0;for(var e in a)a.hasOwnProperty(e)&&(safeSet(e,a[e]),n++);return n}function T(a){return a.version&&!a.files&&a.services}function B(a){var n={};a.customServices&&(n["custom-services"]=JSON.stringify(a.customServices)),a.customApps&&(n["custom-apps"]=JSON.stringify(a.customApps)),a.weatherZip&&(n["weather-zip"]=a.weatherZip),a.theme&&(n.theme=a.theme),a.userThemes&&Object.keys(a.userThemes).length&&(n["user-themes"]=JSON.stringify(a.userThemes)),L(n),a.services&&Array.isArray(a.services)&&secureFetch("/api/v1/services",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a.services)}).catch(function(){}),a.userThemes&&Object.keys(a.userThemes).forEach(function(e){var t=a.userThemes[e],i={};(window.THEME_PROPS||[]).forEach(function(r){t[r]&&(i[r]=t[r])}),secureFetch("/api/v1/themes/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t.name||e,colors:i})}).catch(function(){})})}injectModal("backup-modal",`<div id="backup-modal" class="weather-modal">
`}).join(""):x.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(u){console.error("Failed to load notification history:",u)}}async function m(){try{const u={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},x=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)})).json();x.success?(showNotification("Notification settings saved","success",3e3),f.classList.remove("show")):showNotification(`Failed to save: ${x.error}`,"error",3e3)}catch(u){showNotification(`Error: ${u.message}`,"error",3e3)}}async function h(u){try{const x=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:u})})).json();x.success?showNotification(`Test ${u} notification sent!`,"success",3e3):showNotification(`Test failed: ${x.error}`,"error",3e3)}catch(y){showNotification(`Error: ${y.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>h("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>h("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>h("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>h("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const y=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();y.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(y.lastCheck).toLocaleString()} (${y.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(u){showNotification(`Error: ${u.message}`,"error",3e3)}}),E?.addEventListener("click",()=>{f.classList.add("show"),B(),C()}),M?.addEventListener("click",m),wireModal(f,k)})(),(function(){document.addEventListener("click",f=>{const E=f.target.closest(".panel-tab");if(!E)return;const M=E.dataset.panel;if(!M)return;const k=E.closest(".panel-tabs"),S=k.closest(".weather-modal-content");k.querySelectorAll(".panel-tab").forEach(B=>B.classList.remove("active")),E.classList.add("active"),S.querySelectorAll(".panel-section").forEach(B=>B.classList.remove("active"));const D=S.querySelector("#"+M);D&&D.classList.add("active")})})(),(function(){var f=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function E(){for(var a={},n=0;n<f.length;n++){var e=f[n],t=safeGet(e);t!=null&&(a[e]=t)}try{for(var s=0;s<localStorage.length;s++){var r=localStorage.key(s);/^widget-.+-enabled$/.test(r)&&(a[r]=localStorage.getItem(r))}}catch{}return a}function M(a){if(!a||typeof a!="object")return 0;var n=0;for(var e in a)a.hasOwnProperty(e)&&(safeSet(e,a[e]),n++);return n}function k(a){return a.version&&!a.files&&a.services}function S(a){var n={};a.customServices&&(n["custom-services"]=JSON.stringify(a.customServices)),a.customApps&&(n["custom-apps"]=JSON.stringify(a.customApps)),a.weatherZip&&(n["weather-zip"]=a.weatherZip),a.theme&&(n.theme=a.theme),a.userThemes&&Object.keys(a.userThemes).length&&(n["user-themes"]=JSON.stringify(a.userThemes)),M(n),a.services&&Array.isArray(a.services)&&secureF Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle">
@@ -914,7 +976,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 N=document.getElementById("backup-modal"),D=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),$=document.getElementById("backup-export-btn"),P=document.getElementById("backup-select-file"),C=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),O=document.getElementById("backup-preview-content"),z=document.getElementById("backup-do-restore-btn"),S=document.getElementById("backup-result"),E=document.getElementById("backup-schedule-container"),k=document.getElementById("backup-history-container"),b=null;D?.addEventListener("click",function(){N.classList.add("show"),S&&(S.style.display="none"),x&&(x.style.display="none"),H&&(H.style.display="none"),b=null}),wireModal(N,A),$?.addEventListener("click",async function(){$.disabled=!0,$.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var a=await fetch("/api/v1/backup/export"),n=await a.json();n.browserState=h();var e=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),t=URL.createObjectURL(e),i=document.createElement("a");i.href=t,i.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(t);var r=Object.keys(n.browserState).length,p=n.themes?Object.keys(n.themes).length:0;S.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(p?" + "+p+" themes":""),S.style.display="block",S.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",S.style.border="1px solid var(--ok-fg)"}catch(v){S.innerHTML="\u274C Export failed: "+escapeHtml(v.message),S.style.display="block",S.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",S.style.border="1px solid var(--bad-fg)"}$.disabled=!1,$.innerHTML="\u2B07\uFE0F Download Full Backup"}),P?.addEventListener("click",function(){C.click()}),C?.addEventListener("change",async function(a){var n=a.target.files[0];if(n){H.textContent="\u{1F4C4} "+n.name,H.style.display="block",S.style.display="none";try{var e=await n.text(),t=JSON.parse(e);if(T(t)){b=t;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";i+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),i+="</div>",O.innerHTML=i,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),p=await r.json();if(p.success){b=t;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")</div>";i+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',i+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var v in p.preview.files){var o=p.preview.files[v],s=o.action==="create"?"\u{1F195}":"\u{1F4DD}";i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+s+" "+escapeHtml(o.description)+"</span>"}i+="</div>",p.preview.serviceCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+p.preview.serviceCount+" services</div>"),p.preview.themeCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+p.preview.themeCount+" custom themes</div>"),p.preview.browserStateCount&&(i+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',i+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+p.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),O.innerHTML=i,x.style.display="block"}else S.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(p.error),S.style.display="block",S.style.background="color-mix(in srgb, #f39c12 15%, transparent)",S.style.border="1px solid #f39c12",x.style.display="none"}catch(u){S.innerHTML="\u274C Could not read file: "+escapeHtml(u.message),S.style.display="block",S.style.background="color-mix(in Line truncated
</div>`);var D=document.getElementById("backup-modal"),B=document.getElementById("backup-restore-btn"),C=document.getElementById("backup-cancel"),m=document.getElementById("backup-export-btn"),h=document.getElementById("backup-select-file"),u=document.getElementById("backup-file-input"),y=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),O=document.getElementById("backup-preview-content"),A=document.getElementById("backup-do-restore-btn"),N=document.getElementById("backup-result"),z=document.getElementById("backup-schedule-container"),H=document.getElementById("backup-history-container"),L=null;B?.addEventListener("click",function(){D.classList.add("show"),N&&(N.style.display="none"),x&&(x.style.display="none"),y&&(y.style.display="none"),L=null}),wireModal(D,C),m?.addEventListener("click",async function(){m.disabled=!0,m.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var a=await fetch("/api/v1/backup/export"),n=await a.json();n.browserState=E();var e=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),t=URL.createObjectURL(e),s=document.createElement("a");s.href=t,s.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(s),s.click(),document.body.removeChild(s),URL.revokeObjectURL(t);var r=Object.keys(n.browserState).length,p=n.themes?Object.keys(n.themes).length:0;N.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(p?" + "+p+" themes":""),N.style.display="block",N.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",N.style.border="1px solid var(--ok-fg)"}catch(b){N.innerHTML="\u274C Export failed: "+escapeHtml(b.message),N.style.display="block",N.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",N.style.border="1px solid var(--bad-fg)"}m.disabled=!1,m.innerHTML="\u2B07\uFE0F Download Full Backup"}),h?.addEventListener("click",function(){u.click()}),u?.addEventListener("change",async function(a){var n=a.target.files[0];if(n){y.textContent="\u{1F4C4} "+n.name,y.style.display="block",N.style.display="none";try{var e=await n.text(),t=JSON.parse(e);if(k(t)){L=t;var s='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";s+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),s+="</div>",O.innerHTML=s,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),p=await r.json();if(p.success){L=t;var s='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")</div>";s+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',s+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var b in p.preview.files){var o=p.preview.files[b],i=o.action==="create"?"\u{1F195}":"\u{1F4DD}";s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+i+" "+escapeHtml(o.description)+"</span>"}s+="</div>",p.preview.serviceCount&&(s+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+p.preview.serviceCount+" services</div>"),p.preview.themeCount&&(s+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+p.preview.themeCount+" custom themes</div>"),p.preview.browserStateCount&&(s+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',s+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+p.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),O.innerHTML=s,x.style.display="block"}else N.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(p.error),N.style.display="block",N.style.background="color-mix(in srgb, #f39c12 15%, transparent)",N.style.border="1px solid #f39c12",x.style.display="none"}catch(v){N.innerHTML="\u274C Could not read file: "+escapeHtml(v.message),N.style.display="block",N.style.background="color-mix(in Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F4CA} Resource Monitor</h3>
<p class="modal-subtitle">
@@ -972,11 +1034,11 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="stats-cancel">Close</button>
</div>
</div>
</div>`);const y=document.getElementById("stats-modal"),h=document.getElementById("container-stats-btn"),L=document.getElementById("stats-cancel"),T=document.getElementById("stats-refresh-btn"),B=document.getElementById("stats-auto-refresh"),N=document.getElementById("stats-container"),D=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),$=document.getElementById("stats-last-update");let P=null,C=null;function H(m){if(m===0||!m)return"0 B";const f=1024,c=["B","KB","MB","GB"],d=Math.floor(Math.log(m)/Math.log(f));return parseFloat((m/Math.pow(f,d)).toFixed(1))+" "+c[d]}function x(m){return m<30?"#2ecc71":m<70?"#f39c12":"#e74c3c"}function O(m){return m<50?"#2ecc71":m<80?"#f39c12":"#e74c3c"}async function z(){try{let m=null,f=!1;try{const a=await(await fetch("/api/v1/monitoring/stats")).json();a.success&&a.stats&&(m=a.stats,f=!0,C=a.stats)}catch{}if(!f){const a=await(await fetch("/api/v1/stats/containers")).json();if(a.success&&a.stats){m={};for(const n of a.stats)m[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};C=m}}if(!m||Object.keys(m).length===0){N.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[d,a]of Object.entries(m)){const n=a.current||a,e=n.cpu?.percent||0,t=n.memory?.percent||0,i=x(e),r=O(t),p=n.memory?.usage||n.memory?.used||0,v=n.memory?.limit||0,o=n.network?.rxBytes||n.network?.rx||0,s=n.network?.txBytes||n.network?.tx||0,u=a.aggregated;c+=`
</div>`);const f=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),M=document.getElementById("stats-cancel"),k=document.getElementById("stats-refresh-btn"),S=document.getElementById("stats-auto-refresh"),D=document.getElementById("stats-container"),B=document.getElementById("stats-aggregated-container"),C=document.getElementById("stats-alerts-container"),m=document.getElementById("stats-last-update");let h=null,u=null;function y(g){if(g===0||!g)return"0 B";const I=1024,c=["B","KB","MB","GB"],l=Math.floor(Math.log(g)/Math.log(I));return parseFloat((g/Math.pow(I,l)).toFixed(1))+" "+c[l]}function x(g){return g<30?"#2ecc71":g<70?"#f39c12":"#e74c3c"}function O(g){return g<50?"#2ecc71":g<80?"#f39c12":"#e74c3c"}async function A(){try{let g=null,I=!1;try{const a=await(await fetch("/api/v1/monitoring/stats")).json();a.success&&a.stats&&(g=a.stats,I=!0,u=a.stats)}catch{}if(!I){const a=await(await fetch("/api/v1/stats/containers")).json();if(a.success&&a.stats){g={};for(const n of a.stats)g[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};u=g}}if(!g||Object.keys(g).length===0){D.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[l,a]of Object.entries(g)){const n=a.current||a,e=n.cpu?.percent||0,t=n.memory?.percent||0,s=x(e),r=O(t),p=n.memory?.usage||n.memory?.used||0,b=n.memory?.limit||0,o=n.network?.rxBytes||n.network?.rx||0,i=n.network?.txBytes||n.network?.tx||0,v=a.aggregated;c+=`
<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;">${a.name||d}</span>
${u?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${u.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
<span style="font-weight: 600; flex: 1;">${a.name||l}</span>
${v?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${v.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${a.status||"running"}</span>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
@@ -984,9 +1046,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(e,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div>
<div style="height: 100%; width: ${Math.min(e,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${e.toFixed(1)}%</span>
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${e.toFixed(1)}%</span>
</div>
</div>
<div>
@@ -997,19 +1059,19 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${r}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span>
</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(p)} / ${H(v)}</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${y(p)} / ${y(b)}</div>
</div>
<div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${H(o)}</span>
<span style="color: #3498db;">\u2193 ${y(o)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${H(s)}</span>
<span style="color: #e74c3c;">\u2191 ${y(i)}</span>
</div>
</div>
</div>
</div>`}c+="</div>",N.innerHTML=c,$.textContent="Updated: "+new Date().toLocaleTimeString()}catch(m){N.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(m.message)}</div>`}}async function S(){if(!D)return;const m=C;if(!m||Object.keys(m).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 f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,d]of Object.entries(m)){const a=d.aggregated;a&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${d.name||c}</div>
</div>`}c+="</div>",D.innerHTML=c,m.textContent="Updated: "+new Date().toLocaleTimeString()}catch(g){D.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(g.message)}</div>`}}async function N(){if(!B)return;const g=u;if(!g||Object.keys(g).length===0){B.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 I='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,l]of Object.entries(g)){const a=l.aggregated;a&&(I+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${l.name||c}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${a.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${a.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
@@ -1017,9 +1079,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<div class="stat-mini-card"><span class="stat-val">${a.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div>
${a.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${a.dataPoints} data points over ${a.timeRange||24}h</div>`:""}
</div>`)}f+="</div>",D.innerHTML=f}async function E(){if(!A)return;A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const m=C;if(!m||Object.keys(m).length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,d]of Object.entries(m)){const a=d.alertConfig||{};f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
</div>`)}I+="</div>",B.innerHTML=I}async function z(){if(!C)return;C.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const g=u;if(!g||Object.keys(g).length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let I='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,l]of Object.entries(g)){const a=l.alertConfig||{};I+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${d.name||c}</span>
<span style="font-weight: 600; flex: 1;">${l.name||c}</span>
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
<input type="checkbox" class="alert-enabled" data-container="${c}" ${a.enabled?"checked":""} /> Enabled
</label>
@@ -1045,7 +1107,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<span style="flex: 1;"></span>
<button class="alert-save-btn" data-container="${c}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
</div>
</div>`}f+="</div>",A.innerHTML=f,A.querySelectorAll(".alert-save-btn").forEach(c=>{c.addEventListener("click",async()=>{const d=c.dataset.container,a=A.querySelector(`.alert-enabled[data-container="${d}"]`)?.checked||!1,n=parseInt(A.querySelector(`.alert-cpu[data-container="${d}"]`)?.value)||80,e=parseInt(A.querySelector(`.alert-mem[data-container="${d}"]`)?.value)||85,t=parseInt(A.querySelector(`.alert-cooldown[data-container="${d}"]`)?.value)||15,i=A.querySelector(`.alert-autorestart[data-container="${d}"]`)?.checked||!1;try{const p=await(await secureFetch(`/api/v1/monitoring/alerts/${d}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:a,cpuThreshold:n,memoryThreshold:e,cooldownMinutes:t,autoRestart:i})})).json();c.textContent=p.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{c.textContent="Save"},2e3)}catch{c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save"},2e3)}})})}function k(){P&&clearInterval(P),B?.checked&&(P=setInterval(z,DC.POLL.STATS))}function b(){P&&(clearInterval(P),P=null)}h?.addEventListener("click",()=>{y.classList.add("show"),z(),k()}),L?.addEventListener("click",()=>{y.classList.remove("show"),b()}),y?.addEventListener("click",m=>{m.target===y&&(y.classList.remove("show"),b())}),T?.addEventListener("click",z),B?.addEventListener("change",()=>{B.checked?k():b()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",S),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",E)})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
</div>`}I+="</div>",C.innerHTML=I,C.querySelectorAll(".alert-save-btn").forEach(c=>{c.addEventListener("click",async()=>{const l=c.dataset.container,a=C.querySelector(`.alert-enabled[data-container="${l}"]`)?.checked||!1,n=parseInt(C.querySelector(`.alert-cpu[data-container="${l}"]`)?.value)||80,e=parseInt(C.querySelector(`.alert-mem[data-container="${l}"]`)?.value)||85,t=parseInt(C.querySelector(`.alert-cooldown[data-container="${l}"]`)?.value)||15,s=C.querySelector(`.alert-autorestart[data-container="${l}"]`)?.checked||!1;try{const p=await(await secureFetch(`/api/v1/monitoring/alerts/${l}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:a,cpuThreshold:n,memoryThreshold:e,cooldownMinutes:t,autoRestart:s})})).json();c.textContent=p.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{c.textContent="Save"},2e3)}catch{c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save"},2e3)}})})}function H(){h&&clearInterval(h),S?.checked&&(h=setInterval(A,DC.POLL.STATS))}function L(){h&&(clearInterval(h),h=null)}E?.addEventListener("click",()=>{f.classList.add("show"),A(),H()}),M?.addEventListener("click",()=>{f.classList.remove("show"),L()}),f?.addEventListener("click",g=>{g.target===f&&(f.classList.remove("show"),L())}),k?.addEventListener("click",A),S?.addEventListener("change",()=>{S.checked?H():L()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",N),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",z)})(),(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">
@@ -1131,24 +1193,24 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="health-cancel">Close</button>
</div>
</div>
</div>`);const y=document.getElementById("health-modal"),h=document.getElementById("health-check-btn"),L=document.getElementById("health-cancel"),T=document.getElementById("health-refresh-btn"),B=document.getElementById("health-status-container"),N=document.getElementById("health-incidents-container"),D=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),$=document.getElementById("health-add-btn"),P=document.getElementById("health-config-form"),C=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save");let O=null;function z(c){return c>=99.9?"var(--ok-fg)":c>=95?"#f39c12":"var(--bad-fg)"}function S(c){const d={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: ${d[c]||"var(--muted)"}20; color: ${d[c]||"var(--muted)"};">${c}</span>`}async function E(){try{const d=await(await fetch("/api/v1/health-checks/status")).json();if(!d.success||!d.status||Object.keys(d.status).length===0){B.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 a=Object.values(d.status);let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',n+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',n+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',n+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const e of a){const t=e.status==="up",i=t?"var(--dot-ok)":"var(--dot-bad)",r=e.uptime?.["24h"]??"-",p=e.uptime?.["7d"]??"-",v=e.avgResponseTime!=null?Math.round(e.avgResponseTime)+"ms":"-",o=e.timestamp?timeAgo(e.timestamp):"-";n+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(e.serviceId)}">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${i}; margin-right: 6px;"></span>${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px; color: ${typeof r=="number"?z(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,n+=`<td style="padding: 8px; color: ${typeof p=="number"?z(p):"var(--muted)"};">${typeof p=="number"?p.toFixed(1)+"%":p}</td>`,n+=`<td style="padding: 8px;">${v}</td>`,n+=`<td style="padding: 8px; color: var(--muted);">${o}</td>`,n+="</tr>",n+=`<tr id="health-detail-${escapeHtml(e.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 details...</div></td></tr>`}n+="</table>",B.innerHTML=n,A.textContent="Updated "+new Date().toLocaleTimeString(),B.querySelectorAll("tr[data-health-id]").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.healthId,i=document.getElementById("health-detail-"+t);if(i){if(i.style.display!=="none"){i.style.display="none";return}i.style.display="";try{const p=await(await fetch(`/api/v1/health-checks/${t}/stats?hours=24`)).json();if(p.success&&p.stats){const v=p.stats,o=v.responseTime||{};i.querySelector("td").innerHTML=`
</div>`);const f=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),M=document.getElementById("health-cancel"),k=document.getElementById("health-refresh-btn"),S=document.getElementById("health-status-container"),D=document.getElementById("health-incidents-container"),B=document.getElementById("health-config-container"),C=document.getElementById("health-last-update"),m=document.getElementById("health-add-btn"),h=document.getElementById("health-config-form"),u=document.getElementById("health-form-title"),y=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save");let O=null;function A(c){return c>=99.9?"var(--ok-fg)":c>=95?"#f39c12":"var(--bad-fg)"}function N(c){const l={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: ${l[c]||"var(--muted)"}20; color: ${l[c]||"var(--muted)"};">${c}</span>`}async function z(){try{const l=await(await fetch("/api/v1/health-checks/status")).json();if(!l.success||!l.status||Object.keys(l.status).length===0){S.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 a=Object.values(l.status);let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',n+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',n+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',n+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const e of a){const t=e.status==="up",s=t?"var(--dot-ok)":"var(--dot-bad)",r=e.uptime?.["24h"]??"-",p=e.uptime?.["7d"]??"-",b=e.avgResponseTime!=null?Math.round(e.avgResponseTime)+"ms":"-",o=e.timestamp?timeAgo(e.timestamp):"-";n+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(e.serviceId)}">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${s}; margin-right: 6px;"></span>${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px; color: ${typeof r=="number"?A(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,n+=`<td style="padding: 8px; color: ${typeof p=="number"?A(p):"var(--muted)"};">${typeof p=="number"?p.toFixed(1)+"%":p}</td>`,n+=`<td style="padding: 8px;">${b}</td>`,n+=`<td style="padding: 8px; color: var(--muted);">${o}</td>`,n+="</tr>",n+=`<tr id="health-detail-${escapeHtml(e.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 details...</div></td></tr>`}n+="</table>",S.innerHTML=n,C.textContent="Updated "+new Date().toLocaleTimeString(),S.querySelectorAll("tr[data-health-id]").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.healthId,s=document.getElementById("health-detail-"+t);if(s){if(s.style.display!=="none"){s.style.display="none";return}s.style.display="";try{const p=await(await fetch(`/api/v1/health-checks/${t}/stats?hours=24`)).json();if(p.success&&p.stats){const b=p.stats,o=b.responseTime||{};s.querySelector("td").innerHTML=`
<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>${v.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${z(v.uptime||0)};">${(v.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${b.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${A(b.uptime||0)};">${(b.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(o.avg||0)}ms</strong></div>
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(o.p95||0)}ms / ${Math.round(o.p99||0)}ms</strong></div>
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(o.min||0)}ms</strong></div>
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(o.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${v.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${v.downChecks||0}</strong></div>
</div>`}else i.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){i.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(c){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(c.message)}</div>`}}async function k(){try{const[c,d]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),a=await c.json(),n=await d.json();let e="";const t=a.success&&a.incidents?a.incidents:[];if(t.length>0){e+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+t.length+")</h4>";for(const r of t)e+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${b.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${b.downChecks||0}</strong></div>
</div>`}else s.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){s.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(c){S.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(c.message)}</div>`}}async function H(){try{const[c,l]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),a=await c.json(),n=await l.json();let e="";const t=a.success&&a.incidents?a.incidents:[];if(t.length>0){e+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+t.length+")</h4>";for(const r of t)e+=`<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(r.serviceId)}</span>
<span>${S(r.severity)}</span>
<span>${N(r.severity)}</span>
</div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)</div>
</div>`;e+="</div>"}else e+='<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 i=n.success&&n.history?n.history:[];if(i.length>0){e+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',e+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 r of i){const p=r.status==="resolved",v=p&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,e+=`<td style="padding: 6px;">${S(r.severity)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${p?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,e+=`<td style="padding: 6px;">${v}</td>`,e+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,e+="</tr>"}e+="</table>"}N.innerHTML=e||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(c){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function b(){try{const d=await(await fetch("/api/v1/health-checks/status")).json(),a=d.success&&d.status?Object.values(d.status):[];if(a.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 n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<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 e of a){const t=e.status==="up";n+='<tr style="border-bottom: 1px solid var(--border);">',n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px; color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px;">${e.sla?.target?e.sla.target+"%":"-"}</td>`,n+='<td style="padding: 8px; text-align: right;">',n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,n+="</td></tr>"}n+="</table>",D.innerHTML=n}catch(c){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}function m(c,d,a,n,e,t,i){O=c||null,C.textContent=c?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=c||"",document.getElementById("health-form-id").disabled=!!c,document.getElementById("health-form-name").value=d||"",document.getElementById("health-form-url").value=a||"",document.getElementById("health-form-timeout").value=n||1e4,document.getElementById("health-form-codes").value=e||"200",document.getElementById("health-form-sla").value=t||99.9,document.getElementById("health-form-slow").value=i||5e3,P.style.display="",$.style.display="none"}function f(){P.style.display="none",$.style.display="",O=null}$?.addEventListener("click",()=>m("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",f),x?.addEventListener("click",async()=>{const c=O||document.getElementById("health-form-id").value.trim();if(!c)return showNotification("Service ID is required","warning");const d=document.getElementById("health-form-url").value.trim();if(!d)return showNotification("URL is required","warning");const a=document.getElementById("health-form-codes").value.split(",").map(e=>parseInt(e.trim())).filter(Boolean),n={name:document.getElementById("health-form-name").value.trim()||c,url:d,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:a.length?a:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
</div>`;e+="</div>"}else e+='<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 s=n.success&&n.history?n.history:[];if(s.length>0){e+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',e+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 r of s){const p=r.status==="resolved",b=p&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,e+=`<td style="padding: 6px;">${N(r.severity)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${p?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,e+=`<td style="padding: 6px;">${b}</td>`,e+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,e+="</tr>"}e+="</table>"}D.innerHTML=e||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(c){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function L(){try{const l=await(await fetch("/api/v1/health-checks/status")).json(),a=l.success&&l.status?Object.values(l.status):[];if(a.length===0){B.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 n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<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 e of a){const t=e.status==="up";n+='<tr style="border-bottom: 1px solid var(--border);">',n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px; color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px;">${e.sla?.target?e.sla.target+"%":"-"}</td>`,n+='<td style="padding: 8px; text-align: right;">',n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,n+="</td></tr>"}n+="</table>",B.innerHTML=n}catch(c){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}function g(c,l,a,n,e,t,s){O=c||null,u.textContent=c?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=c||"",document.getElementById("health-form-id").disabled=!!c,document.getElementById("health-form-name").value=l||"",document.getElementById("health-form-url").value=a||"",document.getElementById("health-form-timeout").value=n||1e4,document.getElementById("health-form-codes").value=e||"200",document.getElementById("health-form-sla").value=t||99.9,document.getElementById("health-form-slow").value=s||5e3,h.style.display="",m.style.display="none"}function I(){h.style.display="none",m.style.display="",O=null}m?.addEventListener("click",()=>g("","","",1e4,"200",99.9,5e3)),y?.addEventListener("click",I),x?.addEventListener("click",async()=>{const c=O||document.getElementById("health-form-id").value.trim();if(!c)return showNotification("Service ID is required","warning");const l=document.getElementById("health-form-url").value.trim();if(!l)return showNotification("URL is required","warning");const a=document.getElementById("health-form-codes").value.split(",").map(e=>parseInt(e.trim())).filter(Boolean),n={name:document.getElementById("health-form-name").value.trim()||c,url:l,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:a.length?a:[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">
@@ -1220,17 +1282,120 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="updates-cancel">Close</button>
</div>
</div>
</div>`);const y=document.getElementById("updates-modal"),h=document.getElementById("updates-btn"),L=document.getElementById("updates-cancel"),T=document.getElementById("updates-check-btn"),B=document.getElementById("updates-available-container"),N=document.getElementById("updates-history-container"),D=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function $(){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)throw new Error(v.error);const o=v.updates||[];if(o.length===0){B.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',A.textContent="";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 u of o)s+='<tr style="border-bottom: 1px solid var(--border);">',s+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.containerName)}</td>`,s+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</td>`,s+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.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(u.latestDigest)}</code></td>`,s+='<td style="padding: 8px; text-align: right;">',s+=`<button class="update-now-btn" data-id="${escapeHtml(u.containerId)}" data-name="${escapeHtml(u.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(u.containerId)}" data-name="${escapeHtml(u.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,s+="</td></tr>";s+="</table>",B.innerHTML=s,A.textContent=o.length+" update(s) available",B.querySelectorAll(".update-now-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.dataset.name;if(confirm(`Update "${g}" to the latest version? The container will restart.`)){u.textContent="Updating...",u.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(w.success)u.textContent="Done!",u.style.background="var(--ok-fg)",setTimeout(()=>$(),2e3);else throw new Error(w.error||"Update failed")}catch(I){u.textContent="Failed",u.style.color="var(--bad-fg)",showNotification("Update error: "+I.message,"error"),setTimeout(()=>{u.textContent="Update",u.disabled=!1,u.style.color="",u.style.background=""},3e3)}}})}),B.querySelectorAll(".rollback-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.dataset.name;if(confirm(`Rollback "${g}" to its previous version?`)){u.textContent="Rolling back...",u.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(l)}`,{method:"POST"})).json();if(w.success)u.textContent="Rolled back!",setTimeout(()=>$(),2e3);else throw new Error(w.error||"Rollback failed")}catch(I){u.textContent="Failed",showNotification("Rollback error: "+I.message,"error"),setTimeout(()=>{u.textContent="Rollback",u.disabled=!1},3e3)}}})})}catch(p){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function P(){T.textContent="\u{1F50D} Checking...",T.disabled=!0;try{const v=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!v.success)throw new Error(v.error);T.textContent="\u2705 Done!",await $()}catch(p){T.textContent="\u274C Failed",showNotification("Check error: "+p.message,"error")}setTimeout(()=>{T.textContent="\u{1F50D} Check for Updates",T.disabled=!1},3e3)}async function C(){try{N.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const v=await(await fetch("/api/v1/updates/history?limit=50")).json(),o=v.success&&v.history?v.history:[];if(o.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</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(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
<select class="auto-schedule" data-id="${escapeHtml(g)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value="">Disabled</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select></td>`,s+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(g)}" checked /></td>`,s+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(g)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,s+="</tr>"}s+="</table>",D.innerHTML=s,D.querySelectorAll(".save-auto-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.closest("tr"),I=g.querySelector(".auto-schedule").value,w=g.querySelector(".auto-rollback").checked;u.textContent="Saving...",u.disabled=!0;try{const R=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!I,schedule:I||"weekly",autoRollback:w})})).json();if(R.success)u.textContent="\u2713 Saved";else throw new Error(R.error)}catch(M){u.textContent="\u2717 Error",showNotification("Save error: "+M.message,"error")}setTimeout(()=>{u.textContent="Save",u.disabled=!1},2e3)})})}catch(p){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const x=document.getElementById("dashcaddy-current-version"),O=document.getElementById("dashcaddy-update-badge"),z=document.getElementById("dashcaddy-update-details"),S=document.getElementById("dashcaddy-new-version"),E=document.getElementById("dashcaddy-changelog"),k=document.getElementById("dashcaddy-apply-btn"),b=document.getElementById("dashcaddy-check-btn"),m=document.getElementById("dashcaddy-rollback-btn"),f=document.getElementById("dashcaddy-status-bar"),c=document.getElementById("dashcaddy-history-container");let d=null;function a(p,v){f&&(f.style.display="block",f.style.background=v==="error"?"var(--bad-bg)":v==="success"?"var(--ok-bg)":"var(--bg)",f.style.color=v==="error"?"var(--bad-fg)":v==="success"?"var(--ok-fg)":"var(--fg)",f.textContent=p)}async function n(){try{const v=await(await fetch("/api/v1/system/version")).json();v.success&&(x.textContent="v"+v.version+(v.commit?" ("+v.commit.substring(0,7)+")":""))}catch{x.textContent="Unable to fetch version"}}async function e(p){p||(b.textContent="Checking...",b.disabled=!0);try{const o=await(await fetch("/api/v1/system/update-check")).json();if(d=o,o.success&&o.available&&o.remote){O.style.display="",z.style.display="",S.textContent="v"+o.remote.version,E.textContent=o.remote.changelog||"No changelog available.";const s=document.getElementById("updates-btn");if(s&&!s.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",s.style.position="relative",s.appendChild(l)}const u=document.getElementById("updates-dashcaddy-tab");if(u&&!u.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",u.appendChild(l)}}else O.style.display="none",z.style.display="none",p||a("You are running the latest version.","success");p||(b.textContent="Check for Updates",b.disabled=!1)}catch(v){p||(a("Failed to check: "+v.message,"error"),b.textContent="Check for Updates",b.disabled=!1)}}async function t(){if(confirm("Apply DashCaddy update? The API container will restart.")){k.textContent="Updating...",k.disabled=!0,a("Downloading and applying update...","info");try{const v=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(v.success)a("Update initiated: v"+(v.fromVersion||"?")+" \u2192 v"+(v.toVersion||"?")+". The container will restart shortly.","success"),k.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(o=>o.remove());else throw new Error(v.error||"Update failed")}catch(p){a("Update failed: "+p.message,"error"),k.textContent="Update Now",k.disabled=!1}}}async function i(){try{const v=await(await fetch("/api/v1/system/update-history")).json(),o=v.success&&v.history?v.history:[];if(o.length===0){c.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(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Version</th><th style="padding: 6px; text-align: left;">From</th><th style="padding: 6px; text-align: left;">Status</th></tr>';for(const u of o){const l=u.status==="success"?"\u2713 success":u.status==="pending"?"\u23F3 pending":u.status==="partial"?"\u26A0 partial":"\u2717 "+u.status,g=u.status==="success"?"var(--ok-fg)":u.status==="pending"?"var(--muted)":"var(--bad-fg)";s+='<tr style="border-bottom: 1px solid var(--border);">',s+='<td styLine truncated
</div>`);const f=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),M=document.getElementById("updates-cancel"),k=document.getElementById("updates-check-btn"),S=document.getElementById("updates-available-container"),D=document.getElementById("updates-history-container"),B=document.getElementById("updates-auto-container"),C=document.getElementById("updates-last-check");async function m(){try{const b=await(await fetch("/api/v1/updates/available")).json();if(!b.success)throw new Error(b.error);const o=b.updates||[];if(o.length===0){S.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',C.textContent="";return}let i='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';i+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">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 v of o)i+='<tr style="border-bottom: 1px solid var(--border);">',i+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(v.containerName)}</td>`,i+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(v.imageName)}</td>`,i+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(v.currentDigest)}</code></td>`,i+=`<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(v.latestDigest)}</code></td>`,i+='<td style="padding: 8px; text-align: right;">',i+=`<button class="update-now-btn" data-id="${escapeHtml(v.containerId)}" data-name="${escapeHtml(v.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,i+=`<button class="rollback-btn" data-id="${escapeHtml(v.containerId)}" data-name="${escapeHtml(v.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,i+="</td></tr>";i+="</table>",S.innerHTML=i,C.textContent=o.length+" update(s) available",S.querySelectorAll(".update-now-btn").forEach(v=>{v.addEventListener("click",async()=>{const d=v.dataset.id,w=v.dataset.name;if(confirm(`Update "${w}" to the latest version? The container will restart.`)){v.textContent="Updating...",v.disabled=!0;try{const $=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if($.success)v.textContent="Done!",v.style.background="var(--ok-fg)",setTimeout(()=>m(),2e3);else throw new Error($.error||"Update failed")}catch(T){v.textContent="Failed",v.style.color="var(--bad-fg)",showNotification("Update error: "+T.message,"error"),setTimeout(()=>{v.textContent="Update",v.disabled=!1,v.style.color="",v.style.background=""},3e3)}}})}),S.querySelectorAll(".rollback-btn").forEach(v=>{v.addEventListener("click",async()=>{const d=v.dataset.id,w=v.dataset.name;if(confirm(`Rollback "${w}" to its previous version?`)){v.textContent="Rolling back...",v.disabled=!0;try{const $=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(d)}`,{method:"POST"})).json();if($.success)v.textContent="Rolled back!",setTimeout(()=>m(),2e3);else throw new Error($.error||"Rollback failed")}catch(T){v.textContent="Failed",showNotification("Rollback error: "+T.message,"error"),setTimeout(()=>{v.textContent="Rollback",v.disabled=!1},3e3)}}})})}catch(p){S.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function h(){k.textContent="\u{1F50D} Checking...",k.disabled=!0;try{const b=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!b.success)throw new Error(b.error);k.textContent="\u2705 Done!",await m()}catch(p){k.textContent="\u274C Failed",showNotification("Check error: "+p.message,"error")}setTimeout(()=>{k.textContent="\u{1F50D} Check for Updates",k.disabled=!1},3e3)}async function u(){try{D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const b=await(await fetch("/api/v1/updates/history?limit=50")).json(),o=b.success&&b.history?b.history:[];if(o.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let 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;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
<select class="auto-schedule" data-id="${escapeHtml(P)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${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>`,w+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(P)}" value="${escapeHtml(J)}" 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>`,w+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(P)}"${_?" checked":""} /></td>`,w+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${F}</td>`,w+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(P)}" data-name="${escapeHtml($)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,w+="</tr>"}w+="</table>",B.innerHTML=w,B.querySelectorAll(".save-auto-btn").forEach(T=>{T.addEventListener("click",async()=>{const $=T.dataset.id,P=T.closest("tr"),R=P.querySelector(".auto-schedule").value,U=P.querySelector(".auto-rollback").checked,_=P.querySelector(".auto-window").value.trim();T.textContent="Saving...",T.disabled=!0;try{const F=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent($)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!R,schedule:R||"weekly",autoRollback:U,maintenanceWindow:_||void 0})})).json();if(F.success)T.textContent="\u2713 Saved";else throw new Error(F.error)}catch(J){T.textContent="\u2717 Error",showNotification("Save error: "+J.message,"error")}setTimeout(()=>{T.textContent="Save",T.disabled=!1},2e3)})})}catch(p){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const x=document.getElementById("dashcaddy-current-version"),O=document.getElementById("dashcaddy-update-badge"),A=document.getElementById("dashcaddy-update-details"),N=document.getElementById("dashcaddy-new-version"),z=document.getElementById("dashcaddy-changelog"),H=document.getElementById("dashcaddy-apply-btn"),L=document.getElementById("dashcaddy-check-btn"),g=document.getElementById("dashcaddy-rollback-btn"),I=document.getElementById("dashcaddy-status-bar"),c=document.getElementById("dashcaddy-history-container");let l=null;function a(p,b){I&&(I.style.display="block",I.style.background=b==="error"?"var(--bad-bg)":b==="success"?"var(--ok-bg)":"var(--bg)",I.style.color=b==="error"?"var(--bad-fg)":b==="success"?"var(--ok-fg)":"var(--fg)",I.textContent=p)}async function n(){try{const b=await(await fetch("/api/v1/system/version")).json();b.success&&(x.textContent="v"+b.version+(b.commit?" ("+b.commit.substring(0,7)+")":""))}catch{x.textContent="Unable to fetch version"}}async function e(p){p||(L.textContent="Checking...",L.disabled=!0);try{const o=await(await fetch("/api/v1/system/update-check")).json();if(l=o,o.success&&o.available&&o.remote){O.style.display="",A.style.display="",N.textContent="v"+o.remote.version,z.textContent=o.remote.changelog||"No changelog available.";const i=document.getElementById("updates-btn");if(i&&!i.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",i.style.position="relative",i.appendChild(d)}const v=document.getElementById("updates-dashcaddy-tab");if(v&&!v.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",v.appendChild(d)}}else O.style.display="none",A.style.display="none",p||a("You are running the latest version.","success");p||(L.textContent="Check for Updates",L.disabled=!1)}catch(b){p||(a("Failed to check: "+b.message,"error"),L.textContent="Check for Updates",L.disabled=!1)}}async function t(){if(confirm("Apply DashCaddy update? The API container will restart.")){H.textContent="Updating...",H.disabled=!0,a("Downloading and applying update...","info");try{const b=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(b.success)a("Update initiated: v"+(b.fromVersion||"?")+" \u2192 v"+(b.toVersion||"?")+". The container will restart shortly.","success"),H.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(o=>o.remove());else throw new Error(b.error||"Update failed")}catch(p){a("Update failed: "+p.message,"error"),H.textContent="Update Now",H.disabled=!1}}}async function s(){try{const b=await(await fetch("/api/v1/system/update-history")).json(),o=b.success&&b.history?b.history:[];if(o.length===0){c.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let 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;">When</th><th style="paLine truncated
`+o.join(`
`)+`
Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification("Invalid version: "+s,"error");return}if(!confirm("Rollback DashCaddy to v"+s+"? The container will restart."))return;a("Rolling back to v"+s+"...","info");const l=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:s})})).json();if(l.success)a("Rollback to v"+s+" initiated. Container will restart.","success");else throw new Error(l.error||"Rollback failed")}catch(p){a("Rollback failed: "+p.message,"error")}}b?.addEventListener("click",()=>e(!1)),k?.addEventListener("click",t),m?.addEventListener("click",r),T?.addEventListener("click",P),h?.addEventListener("click",()=>{y?.classList.add("show"),$()}),wireModal(y,L),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",C),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{n(),i(),d||e(!0)}),setTimeout(()=>e(!0),5e3)})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
Enter version to rollback to:`);if(!i)return;if(!o.includes(i)){showNotification("Invalid version: "+i,"error");return}if(!confirm("Rollback DashCaddy to v"+i+"? The container will restart."))return;a("Rolling back to v"+i+"...","info");const d=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:i})})).json();if(d.success)a("Rollback to v"+i+" initiated. Container will restart.","success");else throw new Error(d.error||"Rollback failed")}catch(p){a("Rollback failed: "+p.message,"error")}}L?.addEventListener("click",()=>e(!1)),H?.addEventListener("click",t),g?.addEventListener("click",r),k?.addEventListener("click",h),E?.addEventListener("click",()=>{f?.classList.add("show"),m()}),wireModal(f,M),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",u),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",y),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{n(),s(),l||e(!0)}),setTimeout(()=>e(!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>
<div class="panel-tabs">
<button class="panel-tab active" data-panel="dr-volumes">Volumes</button>
<button class="panel-tab" data-panel="dr-networks">Networks</button>
<button class="panel-tab" data-panel="dr-disk">Disk Usage</button>
</div>
<!-- Volumes -->
<div id="dr-volumes" class="panel-section active">
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<input type="text" id="dr-vol-name" placeholder="Volume name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
<button id="dr-vol-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
</div>
<div id="dr-vol-list" class="scroll-container" style="max-height: 400px;">
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
</div>
</div>
<!-- Networks -->
<div id="dr-networks" class="panel-section">
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<input type="text" id="dr-net-name" placeholder="Network name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
<select id="dr-net-driver" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
<option value="bridge">bridge</option>
<option value="overlay">overlay</option>
<option value="host">host</option>
</select>
<button id="dr-net-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
</div>
<div id="dr-net-list" class="scroll-container" style="max-height: 400px;">
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
</div>
</div>
<!-- Disk Usage -->
<div id="dr-disk" class="panel-section">
<div id="dr-disk-content">
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="dr-close">Close</button>
</div>
</div>
</div>`);const f=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),M=document.getElementById("dr-close");function k(C){if(!C||C===0)return"0 B";const m=["B","KB","MB","GB","TB"],h=Math.floor(Math.log(Math.abs(C))/Math.log(1024));return(C/Math.pow(1024,h)).toFixed(1)+" "+m[h]}async function S(){const C=document.getElementById("dr-vol-list");try{const h=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(h.length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let u='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 y of h){const x=y.name==="buildkit"||y.name.length===64;u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(y.name)}">${escapeHtml(y.name.length>40?y.name.substring(0,37)+"...":y.name)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.driver)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.scope)}</td>`,u+='<td style="padding: 6px; text-align: right;">',x||(u+=`<button class="dr-vol-del" data-name="${escapeHtml(y.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),u+="</td></tr>"}u+="</table>",C.innerHTML=u,C.querySelectorAll(".dr-vol-del").forEach(y=>{y.addEventListener("click",async()=>{if(confirm(`Delete volume "${y.dataset.name}"? Data will be lost.`)){y.textContent="...",y.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(y.dataset.name)}?force=true`),S()}catch(x){showNotification("Delete failed: "+x.message,"error"),y.textContent="Delete",y.disabled=!1}}})})}catch(m){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const C=document.getElementById("dr-vol-name"),m=C.value.trim();if(!m){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:m}),C.value="",showNotification(`Volume "${m}" created`,"success"),S()}catch(h){showNotification("Create failed: "+h.message,"error")}});async function D(){const C=document.getElementById("dr-net-list");try{const h=(await getJSON("/api/v1/docker/networks")).networks||[];if(h.length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let u='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 y of h){const x=["bridge","host","none"].includes(y.name);u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(y.name)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.driver)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.scope)}</td>`,u+=`<td style="padding: 6px; text-align: center;">${y.containers}</td>`,u+='<td style="padding: 6px; text-align: right;">',x||(u+=`<button class="dr-net-del" data-id="${escapeHtml(y.id)}" data-name="${escapeHtml(y.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),u+="</td></tr>"}u+="</table>",C.innerHTML=u,C.querySelectorAll(".dr-net-del").forEach(y=>{y.addEventListener("click",async()=>{if(confirm(`Delete network "${y.dataset.name}"?`)){y.textContent="...",y.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(y.dataset.id)}`),D()}catch(x){showNotification("Delete failed: "+x.message,"error"),y.textContent="Delete",y.disabled=!1}}})})}catch(m){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const C=document.getElementById("dr-net-name"),m=document.getElementById("dr-net-driver"),h=C.value.trim();if(!h){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:h,driver:m.value}),C.value="",showNotification(`Network "${h}" created`,"success"),D()}catch(u){showNotification("Create failed: "+u.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>
<!-- Step 1: Paste YAML -->
<div id="compose-step-paste">
<div style="margin-bottom: 12px;">
<label class="form-label-accent-sm">Stack Name</label>
<input type="text" id="compose-stack-name" placeholder="my-stack" value="" style="width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
</div>
<div style="margin-bottom: 12px;">
<label class="form-label-accent-sm">docker-compose.yml</label>
<textarea id="compose-yaml" rows="14" placeholder="version: '3'&#10;services:&#10; web:&#10; image: nginx:latest&#10; ports:&#10; - '8080:80'" style="width: 100%; padding: 10px; font-family: monospace; font-size: 0.82rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); resize: vertical;"></textarea>
<div style="margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--muted);">
<input type="file" id="compose-file-upload" accept=".yml,.yaml" style="display: none;" />
<span style="text-decoration: underline;">or upload a file</span>
</label>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="compose-parse-btn" class="btn-accent-solid" style="padding: 8px 20px;">Parse & Preview</button>
<button id="compose-cancel">Cancel</button>
</div>
</div>
<!-- Step 2: Preview -->
<div id="compose-step-preview" style="display: none;">
<div id="compose-preview-content"></div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;">
<button id="compose-deploy-btn" class="btn-accent-solid" style="padding: 8px 20px;">Deploy All</button>
<button id="compose-back-btn">Back</button>
</div>
</div>
<!-- Step 3: Progress -->
<div id="compose-step-progress" style="display: none;">
<div id="compose-progress-content"></div>
</div>
</div>
</div>`);const f=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),M=document.getElementById("compose-cancel");wireModal(f,M);let k=null;function S(B){document.getElementById("compose-step-paste").style.display=B==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=B==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=B==="progress"?"":"none"}E?.addEventListener("click",()=>{S("paste"),k=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",f?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",B=>{const C=B.target.files[0];if(!C)return;const m=new FileReader;m.onload=()=>{document.getElementById("compose-yaml").value=m.result},m.readAsText(C)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const B=document.getElementById("compose-yaml").value.trim(),C=document.getElementById("compose-stack-name").value.trim()||"stack";if(!B){showNotification("Paste a docker-compose.yml","warning");return}const m=document.getElementById("compose-parse-btn"),h=m.textContent;m.textContent="Parsing...",m.disabled=!0;try{const u=await postJSON("/api/v1/apps/import-compose",{yaml:B,stackName:C});k=u,k.stackName=C,D(u),S("preview")}catch(u){showNotification("Parse failed: "+u.message,"error")}finally{m.textContent=h,m.disabled=!1}});function D(B){const C=document.getElementById("compose-preview-content");let m="";B.networks&&B.networks.length>0&&(m+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${B.networks.map(h=>`<code>${escapeHtml(h)}</code>`).join(", ")}</div>`),B.volumes&&B.volumes.length>0&&(m+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${B.volumes.map(h=>`<code>${escapeHtml(h)}</code>`).join(", ")}</div>`),m+=`<div style="font-weight: 600; margin-bottom: 8px;">${B.services.length} service(s)</div>`,m+='<div class="scroll-container" style="max-height: 350px;">';for(const h of B.services){const u=h.skip?"var(--bad-fg)":"var(--border)";if(m+=`<div style="padding: 10px 14px; border: 1px solid ${u}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,m+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(h.name)}`,h.skip&&(m+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(h.reason)}</span>`),m+="</div>",!h.skip&&(m+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(h.image)}</code></div>`,h.ports?.length&&(m+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${h.ports.map(y=>`${y.host}:${y.container}`).join(", ")}</div>`),h.volumes?.length&&(m+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${h.volumes.length}</div>`),Object.keys(h.environment||{}).length&&(m+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(h.environment).length}</div>`),h.envFileWarning&&(m+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(h.envFileWarning)}</div>`),h.resources?.cpus||h.resources?.memory)){const y=[];h.resources.cpus&&y.push(`CPU: ${h.resources.cpus}`),h.resources.memory&&y.push(`Mem: ${h.resources.memory}MB`),m+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${y.join(", ")}</div>`}m+="</div>"}m+="</div>",C.innerHTML=m}document.getElementById("compose-back-btn")?.addEventListener("click",()=>S("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!k)return;const B=document.getElementById("compose-deploy-btn");B.textContent="Deploying...",B.disabled=!0,S("progress");const C=document.getElementById("compose-progress-content");C.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const m=await postJSON("/api/v1/apps/deploy-compose",{services:k.services,networks:k.networks,stackName:k.stackName});let h=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(m.stackName)}" \u2014 Deployment Complete</div>`;h+='<div class="scroll-container" style="max-height: 350px;">';for(const u of m.results){const y=u.status==="deployed"||u.status==="created"?"\u2705":u.status==="exists"?"\u26A1":u.status==="skipped"?"\u23ED":"\u274C";h+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',h+=`${y} <strong>${escapeHtml(u.name)}</strong> (${u.type}) \u2014 ${escapeHtml(u.status)}`,u.error&&(h+=` <span style="color: var(--bad-fg);">${escapeHtml(u.error)}</span>`),u.subdomain&&(h+=` \u2192 <code>${escapeHtml(u.subdomain)}</code>`),u.reason&&(h+=` <span style="color: var(--muted);">(${escapeHtml(u.reason)})</span>`),h+="</div>"}h+="</div>",h+='<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",()=>S("paste"))}finally{B.textContent="Deploy All",B.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 8px; padding-bottom: 12px;">
<button id="exec-close">Close</button>
</div>
</div>
</div>`);const f=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),M=document.getElementById("exec-close");let k=null,S=null,D=null;function B(){if(S){try{S.close()}catch{}S=null}if(k){try{k.dispose()}catch{}k=null}D=null,E.innerHTML=""}function C(m,h){if(B(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${h||m}`,f?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}k=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(D=new FitAddon.FitAddon,k.loadAddon(D)),k.open(E),D&&setTimeout(()=>D.fit(),50);const u=location.protocol==="https:"?"wss:":"ws:";S=new WebSocket(`${u}//${location.host}/ws/exec/${encodeURIComponent(m)}`),S.binaryType="arraybuffer",S.onopen=()=>{if(k.writeln("\x1B[32mConnecting...\x1B[0m"),D){const x=D.proposeDimensions();x&&S.send(JSON.stringify({type:"resize",cols:x.cols,rows:x.rows}))}},S.onmessage=x=>{if(typeof x.data=="string"){try{const O=JSON.parse(x.data);if(O.type==="connected"){k.writeln(`\x1B[32mConnected (${O.shell})\x1B[0m\r
`);return}if(O.type==="error"){k.writeln(`\x1B[31mError: ${O.message}\x1B[0m`);return}if(O.type==="exit"){k.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}k.write(x.data)}else k.write(new Uint8Array(x.data))},S.onclose=()=>{k&&k.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},S.onerror=()=>{k&&k.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},k.onData(x=>{S&&S.readyState===WebSocket.OPEN&&S.send(x)}),k.onResize(({cols:x,rows:O})=>{S&&S.readyState===WebSocket.OPEN&&S.send(JSON.stringify({type:"resize",cols:x,rows:O}))});const y=()=>{D&&D.fit()};window.addEventListener("resize",y),f._resizeHandler=y}M?.addEventListener("click",()=>{B(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show")}),f?.addEventListener("click",m=>{m.target===f&&(B(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show"))}),window.openExecModal=C})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
<h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle">
@@ -1266,7 +1431,7 @@ Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification
<button id="audit-cancel">Close</button>
</div>
</div>
</div>`);const y=document.getElementById("audit-modal"),h=document.getElementById("audit-log-btn"),L=document.getElementById("audit-cancel"),T=document.getElementById("audit-refresh-btn"),B=document.getElementById("audit-clear-btn"),N=document.getElementById("audit-filter"),D=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let $=0;const P=50;async function C(H){try{H||($=0,D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const x=N.value;let O=`/api/v1/audit-logs?limit=${P}&offset=${$}`;x&&(O+=`&action=${encodeURIComponent(x)}`);const S=await(await fetch(O)).json(),E=S.success&&S.entries?S.entries:[];if(E.length===0&&!H){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',A.style.display="none";return}let k="";H||(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;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const b of E){const m=b.outcome==="success";k+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',k+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(b.timestamp)}</td>`,k+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(b.ip||"-")}</td>`,k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(b.action||"-")}</td>`,k+=`<td style="padding: 6px;">${escapeHtml(b.resource||"-")}</td>`,k+=`<td style="padding: 6px;"><span style="color: ${m?"var(--ok-fg)":"var(--bad-fg)"};">${m?"\u2713":"\u2717"}</span></td>`,k+="</tr>",b.details&&Object.keys(b.details).length>0&&(k+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(b.details,null,2))}</pre></td></tr>`)}if(!H)k+="</table>",D.innerHTML=k;else{const b=D.querySelector("table");b&&b.insertAdjacentHTML("beforeend",k)}$+=E.length,A.style.display=E.length>=P?"":"none",D.querySelectorAll(".audit-row").forEach(b=>{b.dataset.wired||(b.dataset.wired="true",b.addEventListener("click",()=>{const m=b.nextElementSibling;m&&m.classList.contains("audit-detail")&&(m.style.display=m.style.display==="none"?"":"none")}))})}catch(x){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(x.message)}</div>`}}h?.addEventListener("click",()=>{y?.classList.add("show"),C(!1)}),wireModal(y,L),T?.addEventListener("click",()=>C(!1)),N?.addEventListener("change",()=>C(!1)),A?.addEventListener("click",()=>C(!0)),B?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const x=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();x.success?C(!1):showNotification("Error: "+(x.error||"Clear failed"),"error")}catch(H){showNotification("Error: "+H.message,"error")}})})(),(function(){injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
</div>`);const f=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),M=document.getElementById("audit-cancel"),k=document.getElementById("audit-refresh-btn"),S=document.getElementById("audit-clear-btn"),D=document.getElementById("audit-filter"),B=document.getElementById("audit-log-container"),C=document.getElementById("audit-load-more");let m=0;const h=50;async function u(y){try{y||(m=0,B.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const x=D.value;let O=`/api/v1/audit-logs?limit=${h}&offset=${m}`;x&&(O+=`&action=${encodeURIComponent(x)}`);const N=await(await fetch(O)).json(),z=N.success&&N.entries?N.entries:[];if(z.length===0&&!y){B.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',C.style.display="none";return}let H="";y||(H='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',H+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const L of z){const g=L.outcome==="success";H+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',H+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(L.timestamp)}</td>`,H+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(L.ip||"-")}</td>`,H+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(L.action||"-")}</td>`,H+=`<td style="padding: 6px;">${escapeHtml(L.resource||"-")}</td>`,H+=`<td style="padding: 6px;"><span style="color: ${g?"var(--ok-fg)":"var(--bad-fg)"};">${g?"\u2713":"\u2717"}</span></td>`,H+="</tr>",L.details&&Object.keys(L.details).length>0&&(H+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(L.details,null,2))}</pre></td></tr>`)}if(!y)H+="</table>",B.innerHTML=H;else{const L=B.querySelector("table");L&&L.insertAdjacentHTML("beforeend",H)}m+=z.length,C.style.display=z.length>=h?"":"none",B.querySelectorAll(".audit-row").forEach(L=>{L.dataset.wired||(L.dataset.wired="true",L.addEventListener("click",()=>{const g=L.nextElementSibling;g&&g.classList.contains("audit-detail")&&(g.style.display=g.style.display==="none"?"":"none")}))})}catch(x){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(x.message)}</div>`}}E?.addEventListener("click",()=>{f?.classList.add("show"),u(!1)}),wireModal(f,M),k?.addEventListener("click",()=>u(!1)),D?.addEventListener("change",()=>u(!1)),C?.addEventListener("click",()=>u(!0)),S?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const x=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();x.success?u(!1):showNotification("Error: "+(x.error||"Clear failed"),"error")}catch(y){showNotification("Error: "+y.message,"error")}})})(),(function(){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>
@@ -1277,15 +1442,15 @@ Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification
<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 y="weather-location",h="weather-zip",L="weather-geo",T="weather-unit";!safeGet(y)&&safeGet(h)&&safeSet(y,safeGet(h));function B(){return safeGet(T)||"imperial"}function N(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},A={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 P(E){return $[Math.round(E/22.5)%16]}async function C(E){const k=safeGet(L);if(k)try{const d=JSON.parse(k);if(d.query===E)return d}catch{}const b=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(E)}&count=1&language=en&format=json`);if(!b.ok)throw new Error("Geocoding failed");const m=await b.json();if(!m.results||!m.results.length)throw new Error("Location not found");const f=m.results[0],c={query:E,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(L,JSON.stringify(c)),c}function H(E){return E.countryCode==="US"&&E.state?`${E.city}, ${E.state}`:E.country?`${E.city}, ${E.country}`:E.city}async function x(E){try{const k=await C(E),b=B(),m=b==="metric"?"celsius":"fahrenheit",f=b==="metric"?"kmh":"mph",c=`https://api.open-meteo.com/v1/forecast?latitude=${k.lat}&longitude=${k.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${m}&wind_speed_unit=${f}`,d=await fetch(c);if(!d.ok)throw new Error("Weather fetch failed");const n=(await d.json()).current,e=n.weather_code;return{temp:Math.round(n.temperature_2m),condition:D[e]||"Unknown",icon:A[e]||"\u{1F324}\uFE0F",locationStr:H(k),windSpeed:Math.round(n.wind_speed_10m),windDir:P(n.wind_direction_10m),unit:b}}catch(k){return console.warn("Weather fetch failed:",k),null}}async function O(){const E=N();if(!E.icon||!E.temp||!E.condition||!E.location||!E.wind){console.warn("Weather widget elements not found");return}const k=safeGet(y);if(!k){E.location.textContent="Set Location",E.temp.textContent="--\xB0",E.condition.textContent="Click \u2699\uFE0F to configure",E.wind.textContent="--",E.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const b=await x(k);if(b){const m=b.unit==="metric"?"\xB0C":"\xB0F",f=b.unit==="metric"?"km/h":"mph";E.location.textContent=b.locationStr,E.temp.textContent=`${b.temp}${m}`,E.condition.textContent=b.condition,E.wind.textContent=`Wind: ${b.windSpeed} ${f} ${b.windDir}`,E.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(b.icon)}</span>`}}catch(b){console.error("Weather update error:",b),E.location.textContent="Weather Error",E.temp.textContent="Error",E.condition.textContent="Failed to load",E.wind.textContent="--"}}const z=document.getElementById("weather-modal"),S=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{S.value=safeGet(y)||"";const E=B(),k=z.querySelector(`input[name="weather-unit-radio"][value="${E}"]`);k&&(k.checked=!0),z.classList.add("show"),S.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{z.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const E=S.value.trim();if(E){safeGet(y)!==E&&safeSet(L,""),safeSet(y,E);const b=z.querySelector('input[name="weather-unit-radio"]:checked'),m=b?b.value:"imperial",f=B();safeSet(T,m),f!==m&&safeSet(L,""),z.classList.remove("show"),O()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(z),document.addEventListener("keydown",E=>{E.key==="Escape"&&z.classList.Line truncated
<circle cx="${w}" cy="${M}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${j}
${W}
<line x1="${w}" y1="${M}" x2="${w+28*Math.cos(_*Math.PI/180)}" y2="${M+28*Math.sin(_*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${w}" y1="${M}" x2="${w+38*Math.cos(q*Math.PI/180)}" y2="${M+38*Math.sin(q*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${w}" y1="${M}" x2="${w+42*Math.cos(R*Math.PI/180)}" y2="${M+42*Math.sin(R*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${w}" cy="${M}" r="3" fill="var(--fg)"/>
</svg>`,se=o.getHours()>=12?"PM":"AM";h.innerHTML=`<div class="analog-clock-wrap">${V}<div class="analog-info"><span class="analog-digital">${o.getHours()%12||12}:${String(l).padStart(2,"0")} ${se}</span><span class="analog-date-sm">${O(o)}</span></div></div>`,P="analog"}function d(){const o=new Date,s=o.getHours()%12||12,u=o.getMinutes(),l=o.getSeconds(),g="clock-widget"+(N!=="default"?" "+N:"");switch(y.className!==g&&(y.className=g),N){case"lcd":k(o);break;case"lcd-blue":k(o);break;case"lcd-amber":k(o);break;case"lcd-retro":k(o);break;case"lcd-taxi":k(o);break;case"flip":b(o);break;case"binary":f(o);break;case"analog":c(o,!1);break;case"roman":c(o,!0);break;default:E(o)}u===0&&l===0&&s!==D&&(D=s,x(s)),u!==0&&(D=-1)}function a(){clearTimeout(H);const o=document.hidden?6e4:1e3,s=o-Date.now()%o+25;H=setTimeout(()=>{d(),a()},s)}document.addEventListener("visibilitychange",()=>{$="",z(),d(),a()}),d(),a();const n=[{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 e='<div class="clock-style-grid">';n.forEach(o=>{e+=`<label class="clock-style-option">
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const f="weather-location",E="weather-zip",M="weather-geo",k="weather-unit";!safeGet(f)&&safeGet(E)&&safeSet(f,safeGet(E));function S(){return safeGet(k)||"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 B={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"},C={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"},m=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function h(z){return m[Math.round(z/22.5)%16]}async function u(z){const H=safeGet(M);if(H)try{const l=JSON.parse(H);if(l.query===z)return l}catch{}const L=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(z)}&count=1&language=en&format=json`);if(!L.ok)throw new Error("Geocoding failed");const g=await L.json();if(!g.results||!g.results.length)throw new Error("Location not found");const I=g.results[0],c={query:z,lat:I.latitude,lon:I.longitude,city:I.name,state:I.admin1||"",country:I.country||"",countryCode:I.country_code||""};return safeSet(M,JSON.stringify(c)),c}function y(z){return z.countryCode==="US"&&z.state?`${z.city}, ${z.state}`:z.country?`${z.city}, ${z.country}`:z.city}async function x(z){try{const H=await u(z),L=S(),g=L==="metric"?"celsius":"fahrenheit",I=L==="metric"?"kmh":"mph",c=`https://api.open-meteo.com/v1/forecast?latitude=${H.lat}&longitude=${H.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${g}&wind_speed_unit=${I}`,l=await fetch(c);if(!l.ok)throw new Error("Weather fetch failed");const n=(await l.json()).current,e=n.weather_code;return{temp:Math.round(n.temperature_2m),condition:B[e]||"Unknown",icon:C[e]||"\u{1F324}\uFE0F",locationStr:y(H),windSpeed:Math.round(n.wind_speed_10m),windDir:h(n.wind_direction_10m),unit:L}}catch(H){return console.warn("Weather fetch failed:",H),null}}async function O(){const z=D();if(!z.icon||!z.temp||!z.condition||!z.location||!z.wind){console.warn("Weather widget elements not found");return}const H=safeGet(f);if(!H){z.location.textContent="Set Location",z.temp.textContent="--\xB0",z.condition.textContent="Click \u2699\uFE0F to configure",z.wind.textContent="--",z.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const L=await x(H);if(L){const g=L.unit==="metric"?"\xB0C":"\xB0F",I=L.unit==="metric"?"km/h":"mph";z.location.textContent=L.locationStr,z.temp.textContent=`${L.temp}${g}`,z.condition.textContent=L.condition,z.wind.textContent=`Wind: ${L.windSpeed} ${I} ${L.windDir}`,z.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(L.icon)}</span>`}}catch(L){console.error("Weather update error:",L),z.location.textContent="Weather Error",z.temp.textContent="Error",z.condition.textContent="Failed to load",z.wind.textContent="--"}}const A=document.getElementById("weather-modal"),N=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{N.value=safeGet(f)||"";const z=S(),H=A.querySelector(`input[name="weather-unit-radio"][value="${z}"]`);H&&(H.checked=!0),A.classList.add("show"),N.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{A.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const z=N.value.trim();if(z){safeGet(f)!==z&&safeSet(M,""),safeSet(f,z);const L=A.querySelector('input[name="weather-unit-radio"]:checked'),g=L?L.value:"imperial",I=S();safeSet(k,g),I!==g&&safeSet(M,""),A.classList.remove("show"),O()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(A),document.addEventListener("keydown",z=>{z.key==="Escape"&&A.classList.Line truncated
<circle cx="${$}" cy="${P}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F}
${J}
<line x1="${$}" y1="${P}" x2="${$+28*Math.cos(_*Math.PI/180)}" y2="${P+28*Math.sin(_*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${$}" y1="${P}" x2="${$+38*Math.cos(U*Math.PI/180)}" y2="${P+38*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${$}" y1="${P}" x2="${$+42*Math.cos(R*Math.PI/180)}" y2="${P+42*Math.sin(R*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${$}" cy="${P}" r="3" fill="var(--fg)"/>
</svg>`,se=o.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${K}<div class="analog-info"><span class="analog-digital">${o.getHours()%12||12}:${String(d).padStart(2,"0")} ${se}</span><span class="analog-date-sm">${O(o)}</span></div></div>`,h="analog"}function l(){const o=new Date,i=o.getHours()%12||12,v=o.getMinutes(),d=o.getSeconds(),w="clock-widget"+(D!=="default"?" "+D:"");switch(f.className!==w&&(f.className=w),D){case"lcd":H(o);break;case"lcd-blue":H(o);break;case"lcd-amber":H(o);break;case"lcd-retro":H(o);break;case"lcd-taxi":H(o);break;case"flip":L(o);break;case"binary":I(o);break;case"analog":c(o,!1);break;case"roman":c(o,!0);break;default:z(o)}v===0&&d===0&&i!==B&&(B=i,x(i)),v!==0&&(B=-1)}function a(){clearTimeout(y);const o=document.hidden?6e4:1e3,i=o-Date.now()%o+25;y=setTimeout(()=>{l(),a()},i)}document.addEventListener("visibilitychange",()=>{m="",A(),l(),a()}),l(),a();const n=[{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 e='<div class="clock-style-grid">';n.forEach(o=>{e+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${o.id}">
<span class="clock-style-card"><span class="clock-style-icon">${o.icon}</span><span class="clock-style-label">${o.label}</span></span>
</label>`}),e+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
@@ -1317,8 +1482,8 @@ Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification
<button id="clock-settings-save">Save</button>
</div>
</div>
</div>`);const t=document.getElementById("clock-settings-modal"),i=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function v(){const o=safeGet("clock-style")||"default",s=t.querySelector(`input[value="${o}"]`);s&&(s.checked=!0),i.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=i.checked?"1":"0.4"}i?.addEventListener("change",()=>{p.style.opacity=i.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{v(),t.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const o=parseInt(r.value,10)/100,s=new Audio("/assets/sounds/church-bell.mp3");s.volume=o,s.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const o=t.querySelector('input[name="clock-style-radio"]:checked'),s=o?o.value:"default";safeSet("clock-style",s),safeSet("clock-chimes",String(i.checked)),safeSet("clock-chime-volume",r.value),N=s,$="",z(),d(),a(),t.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{t.classList.remove("show")}),wireModal(t),t?.querySelectorAll('input[name="clock-style-radio"]').forEach(o=>{o.addEventListener("change",()=>{N=o.value,$="",z(),d()})})})(),(function(){async function y(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[A,$]of Object.entries(D.status)){const P=document.getElementById("uptime-"+A),C=document.getElementById("uptime-bar-"+A);if(!P)continue;const H=$.uptime?.["24h"];if(H!=null){const x=H.toFixed(1);P.textContent=`${x}% uptime`,P.className="uptime-chip",H>=99.9?P.classList.add("excellent"):H>=99?P.classList.add("good"):H>=95?P.classList.add("degraded"):P.classList.add("poor"),C&&(C.style.width=x+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let h;try{h=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{h=new Set}async function L(){try{const D=await(await fetch("/api/v1/updates/available")).json();if(!D.success||(document.querySelectorAll(".update-available-badge").forEach(A=>A.classList.remove("visible")),!D.updates?.length))return;for(const A of D.updates){const $=window.APPS||[];for(const P of $)if(P.containerId===A.containerId||P.id===A.containerName||P.name===A.containerName){if(h.has(P.id))break;const C=document.getElementById("update-badge-"+P.id);C&&(C.classList.add("visible"),C.title=`Image digest changed. Click to dismiss if already up to date.
${A.imageName||""}`,C.style.cursor="pointer",C.onclick=H=>{H.stopPropagation(),C.classList.remove("visible"),h.add(P.id),safeSessionSet("dismissed-updates",JSON.stringify([...h]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function T(){setTimeout(()=>{y(),L()},5e3),setInterval(()=>{y(),L()},6e4)}const B=window.refreshAll;B&&(window.refreshAll=async function(){try{await B(),setTimeout(y,1e3)}catch(N){console.warn("[Card Badges] Error in refreshAll hook:",N.message)}}),T()})(),(function(){var y=null,h=null,L={},T={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},B=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],N=document.getElementById("theme");if(!N)return;var D=document.getElementById("theme-label");function A(l){if(T[l])return T[l];var g=safeGetJSON(window.USER_THEMES_KEY,{});return g[l]&&g[l].name||l}function $(){D&&(D.textContent=A(window.getActiveTheme()))}N.addEventListener("click",function(){var l=window.THEMES.slice(),g=window.getActiveTheme(),I=l.indexOf(g),w=l[(I+1)%l.length];window.applyTheme(w),$()}),$();function P(){var l={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},g={};B.forEach(function(w){g[w[2]]||(g[w[2]]=[]),g[w[2]].push(w)});var I="";return Object.keys(l).forEach(function(w){w==="advanced"?(I+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors &#9660;</div>',I+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):I+='<div class="theme-builder-section">',I+='<div class="theme-builder-section-title">'+l[w]+"</div>",(g[w]||[]).forEach(function(M){I+='<div class="theme-builder-row"><span class="theme-builder-label">'+M[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+M[0]+'"'+(w==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+M[0]+'">#000000</span></div>'}),I+="</div>"}),I}function C(){return window.THEMES.map(function(l){return'<option value="'+l+'">'+A(l)+"</option>"}).join("")}var H='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+C()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</ Line truncated
</div>`);const t=document.getElementById("clock-settings-modal"),s=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function b(){const o=safeGet("clock-style")||"default",i=t.querySelector(`input[value="${o}"]`);i&&(i.checked=!0),s.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=s.checked?"1":"0.4"}s?.addEventListener("change",()=>{p.style.opacity=s.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{b(),t.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const o=parseInt(r.value,10)/100,i=new Audio("/assets/sounds/church-bell.mp3");i.volume=o,i.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const o=t.querySelector('input[name="clock-style-radio"]:checked'),i=o?o.value:"default";safeSet("clock-style",i),safeSet("clock-chimes",String(s.checked)),safeSet("clock-chime-volume",r.value),D=i,m="",A(),l(),a(),t.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{t.classList.remove("show")}),wireModal(t),t?.querySelectorAll('input[name="clock-style-radio"]').forEach(o=>{o.addEventListener("change",()=>{D=o.value,m="",A(),l()})})})(),(function(){async function f(){try{const B=await(await fetch("/api/v1/health-checks/status")).json();if(!B.success||!B.status)return;for(const[C,m]of Object.entries(B.status)){const h=document.getElementById("uptime-"+C),u=document.getElementById("uptime-bar-"+C);if(!h)continue;const y=m.uptime?.["24h"];if(y!=null){const x=y.toFixed(1);h.textContent=`${x}% uptime`,h.className="uptime-chip",y>=99.9?h.classList.add("excellent"):y>=99?h.classList.add("good"):y>=95?h.classList.add("degraded"):h.classList.add("poor"),u&&(u.style.width=x+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}async function M(){try{const B=await(await fetch("/api/v1/updates/available")).json();if(!B.success||(document.querySelectorAll(".update-available-badge").forEach(C=>C.classList.remove("visible")),!B.updates?.length))return;for(const C of B.updates){const m=window.APPS||[];for(const h of m)if(h.containerId===C.containerId||h.id===C.containerName||h.name===C.containerName){if(E.has(h.id))break;const u=document.getElementById("update-badge-"+h.id);u&&(u.classList.add("visible"),u.title=`Image digest changed. Click to dismiss if already up to date.
${C.imageName||""}`,u.style.cursor="pointer",u.onclick=y=>{y.stopPropagation(),u.classList.remove("visible"),E.add(h.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function k(){setTimeout(()=>{f(),M()},5e3),setInterval(()=>{f(),M()},6e4)}const S=window.refreshAll;S&&(window.refreshAll=async function(){try{await S(),setTimeout(f,1e3)}catch(D){console.warn("[Card Badges] Error in refreshAll hook:",D.message)}}),k()})(),(function(){var f=null,E=null,M={},k={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},S=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],D=document.getElementById("theme");if(!D)return;var B=document.getElementById("theme-label");function C(d){if(k[d])return k[d];var w=safeGetJSON(window.USER_THEMES_KEY,{});return w[d]&&w[d].name||d}function m(){B&&(B.textContent=C(window.getActiveTheme()))}D.addEventListener("click",function(){var d=window.THEMES.slice(),w=window.getActiveTheme(),T=d.indexOf(w),$=d[(T+1)%d.length];window.applyTheme($),m()}),m();function h(){var d={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},w={};S.forEach(function($){w[$[2]]||(w[$[2]]=[]),w[$[2]].push($)});var T="";return Object.keys(d).forEach(function($){$==="advanced"?(T+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors &#9660;</div>',T+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):T+='<div class="theme-builder-section">',T+='<div class="theme-builder-section-title">'+d[$]+"</div>",(w[$]||[]).forEach(function(P){T+='<div class="theme-builder-row"><span class="theme-builder-label">'+P[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+P[0]+'"'+($==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+P[0]+'">#000000</span></div>'}),T+="</div>"}),T}function u(){return window.THEMES.map(function(d){return'<option value="'+d+'">'+C(d)+"</option>"}).join("")}var y='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+u()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</ 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;">
@@ -1355,13 +1520,13 @@ ${A.imageName||""}`,C.style.cursor="pointer",C.onclick=H=>{H.stopPropagation(),C
<button id="license-activate" class="btn-accent">Activate</button>
</div>
</div>
</div>`);const y=document.getElementById("license-modal"),h=document.getElementById("license-code-input"),L=document.getElementById("license-activate"),T=document.getElementById("license-deactivate"),B=document.getElementById("license-error"),N=document.getElementById("license-success"),D=document.getElementById("license-badge-icon"),A=document.getElementById("license-badge-text"),$=document.getElementById("license-badge"),P=document.getElementById("license-details"),C=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let x=null;function O(){B.style.display="none",N.style.display="none"}function z(a){O(),B.textContent=a,B.style.display="block"}function S(a){O(),N.textContent=a,N.style.display="block"}function E(a){if(x=a,a.active){$.style.background="rgba(46,204,113,0.15)",$.style.color="var(--ok-fg)",D.textContent="\u2605",A.textContent="Premium Active";const t=a.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(a.expiresAt).toLocaleDateString()}</strong> (${a.daysRemaining} days remaining)</div>`;P.innerHTML=`
</div>`);const f=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),M=document.getElementById("license-activate"),k=document.getElementById("license-deactivate"),S=document.getElementById("license-error"),D=document.getElementById("license-success"),B=document.getElementById("license-badge-icon"),C=document.getElementById("license-badge-text"),m=document.getElementById("license-badge"),h=document.getElementById("license-details"),u=document.getElementById("license-feature-list"),y=document.getElementById("license-activate-section");let x=null;function O(){S.style.display="none",D.style.display="none"}function A(a){O(),S.textContent=a,S.style.display="block"}function N(a){O(),D.textContent=a,D.style.display="block"}function z(a){if(x=a,a.active){m.style.background="rgba(46,204,113,0.15)",m.style.color="var(--ok-fg)",B.textContent="\u2605",C.textContent="Premium Active";const t=a.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(a.expiresAt).toLocaleDateString()}</strong> (${a.daysRemaining} days remaining)</div>`;h.innerHTML=`
<div>Code: <code style="font-family: monospace;">${a.code||"***"}</code></div>
${t}
`,H.style.display="none",L.style.display="none",T.style.display=""}else $.style.background="rgba(149,165,166,0.15)",$.style.color="var(--muted)",D.textContent="\u2606",A.textContent=a.expired?"License Expired":"Free Tier",P.innerHTML=a.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="",L.style.display="",T.style.display="none";const n=a.premiumFeatures||{},e=new Set(a.features||[]);C.innerHTML=Object.entries(n).map(([t,i])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
`,y.style.display="none",M.style.display="none",k.style.display=""}else m.style.background="rgba(149,165,166,0.15)",m.style.color="var(--muted)",B.textContent="\u2606",C.textContent=a.expired?"License Expired":"Free Tier",h.innerHTML=a.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",y.style.display="",M.style.display="",k.style.display="none";const n=a.premiumFeatures||{},e=new Set(a.features||[]);u.innerHTML=Object.entries(n).map(([t,s])=>`<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;">${e.has(t)?"\u2705":"\u{1F512}"}</span>
<div>
<div style="font-weight: 600; font-size: 0.9rem;">${i.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${i.description}</div>
<div style="font-weight: 600; font-size: 0.9rem;">${s.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${s.description}</div>
</div>
</div>`).join("")}async function k(){try{const n=await(await fetch("/api/v1/license/status")).json();n.success&&(E(n.license),f(n.license))}catch(a){console.warn("Failed to load license status:",a.message)}}async function b(){const a=h.value.trim();if(!a){z("Please enter a license code.");return}O(),L.disabled=!0,L.textContent="Activating...";try{const e=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:a})})).json();e.success?(S(e.message),h.value="",E(e.license),showNotification("License activated! Premium features unlocked.","success",5e3),f(e.license)):z(e.error||"Activation failed")}catch(n){z("Network error: "+n.message)}finally{L.disabled=!1,L.textContent="Activate"}}async function m(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){T.disabled=!0,T.textContent="Deactivating...";try{const n=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();n.success?(S(n.message),await k(),showNotification("License deactivated.","info",3e3),f({active:!1})):z(n.error||"Deactivation failed")}catch(a){z("Network error: "+a.message)}finally{T.disabled=!1,T.textContent="Deactivate"}}}function f(a){const n=document.getElementById("license-status-topbar"),e=document.getElementById("license-topbar-icon"),t=document.getElementById("license-topbar-text"),i=document.getElementById("license-topbar-time");if(n)if(n.className="license-status-topbar "+(a.active?"premium":"free"),a.active)if(e.textContent="\u2605",t.textContent="PREMIUM",a.lifetime)i.textContent="\xB7 LIFETIME";else{const r=a.daysRemaining;i.textContent=r!=null?"\xB7 "+r+"d remaining":""}else e.textContent="\u2606",t.textContent=a.expired?"EXPIRED":"FREE TIER",i.textContent=""}function c(){O(),k(),y.classList.add("show")}h.addEventListener("input",function(){let a=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(a.length>this._prevLength&&(a=a.replace(/-/g,""),a.length>2&&!a.startsWith("DC")&&(a="DC"+a),a.startsWith("DC")&&a.length>2)){const n=["DC"],e=a.substring(2);for(let t=0;t<e.length;t+=5)n.push(e.substring(t,t+5));a=n.join("-")}this._prevLength=a.length,this.value=a}),L.addEventListener("click",b),T.addEventListener("click",m),h.addEventListener("keydown",a=>{a.key==="Enter"&&b()}),wireModal(y,document.getElementById("license-cancel"));const d=document.getElementById("license-status-topbar");d&&d.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=c,window.checkPremiumFeature=async function(a){try{return(await(await fetch(`/api/v1/license/feature/${a}`)).json()).available}catch{return!1}},k().then(a=>{x&&f(x)})})();
</div>`).join("")}async function H(){try{const n=await(await fetch("/api/v1/license/status")).json();n.success&&(z(n.license),I(n.license))}catch(a){console.warn("Failed to load license status:",a.message)}}async function L(){const a=E.value.trim();if(!a){A("Please enter a license code.");return}O(),M.disabled=!0,M.textContent="Activating...";try{const e=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:a})})).json();e.success?(N(e.message),E.value="",z(e.license),showNotification("License activated! Premium features unlocked.","success",5e3),I(e.license)):A(e.error||"Activation failed")}catch(n){A("Network error: "+n.message)}finally{M.disabled=!1,M.textContent="Activate"}}async function g(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){k.disabled=!0,k.textContent="Deactivating...";try{const n=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();n.success?(N(n.message),await H(),showNotification("License deactivated.","info",3e3),I({active:!1})):A(n.error||"Deactivation failed")}catch(a){A("Network error: "+a.message)}finally{k.disabled=!1,k.textContent="Deactivate"}}}function I(a){const n=document.getElementById("license-status-topbar"),e=document.getElementById("license-topbar-icon"),t=document.getElementById("license-topbar-text"),s=document.getElementById("license-topbar-time");if(n)if(n.className="license-status-topbar "+(a.active?"premium":"free"),a.active)if(e.textContent="\u2605",t.textContent="PREMIUM",a.lifetime)s.textContent="\xB7 LIFETIME";else{const r=a.daysRemaining;s.textContent=r!=null?"\xB7 "+r+"d remaining":""}else e.textContent="\u2606",t.textContent=a.expired?"EXPIRED":"FREE TIER",s.textContent=""}function c(){O(),H(),f.classList.add("show")}E.addEventListener("input",function(){let a=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(a.length>this._prevLength&&(a=a.replace(/-/g,""),a.length>2&&!a.startsWith("DC")&&(a="DC"+a),a.startsWith("DC")&&a.length>2)){const n=["DC"],e=a.substring(2);for(let t=0;t<e.length;t+=5)n.push(e.substring(t,t+5));a=n.join("-")}this._prevLength=a.length,this.value=a}),M.addEventListener("click",L),k.addEventListener("click",g),E.addEventListener("keydown",a=>{a.key==="Enter"&&L()}),wireModal(f,document.getElementById("license-cancel"));const l=document.getElementById("license-status-topbar");l&&l.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=c,window.checkPremiumFeature=async function(a){try{return(await(await fetch(`/api/v1/license/feature/${a}`)).json()).available}catch{return!1}},H().then(a=>{x&&I(x)})})();
+7
View File
@@ -24,6 +24,7 @@
<link rel="stylesheet" href="/css/themes.css">
<link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/xterm.css">
</head>
<body>
@@ -130,6 +131,7 @@
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button>
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
</div>
</div>
@@ -231,6 +233,7 @@
<div style="text-align: center; margin: 32px 0; display: flex; justify-content: center; gap: 16px; flex-wrap: wrap;">
<button id="add-service-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;">📱 App Selector</button>
<button id="add-service" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;" aria-label="Add new app manually">+ Add App Manually</button>
<button id="compose-import-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;">📦 Import Compose</button>
<button id="arr-setup-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none;">🎬 Smart Arr Connect</button>
</div>
@@ -545,6 +548,10 @@
<img src="/assets/sami7777-logo.png" alt="samiahmed7777" class="footer-logo">
</footer>
<!-- xterm.js for container exec/shell -->
<script src="/js/xterm.min.js" defer></script>
<script src="/js/xterm-fit.min.js" defer></script>
<!-- Bundled JS (built with: npm run build) -->
<script src="/dist/core.js" defer></script>
<script src="/dist/features.js" defer></script>
+21 -1
View File
@@ -181,6 +181,22 @@
</div>
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
</div>
<div style="margin-top: 12px;">
<label class="form-label-accent-sm">⚙️ Resource Limits</label>
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
Optional CPU and memory constraints. Leave at 0 for unlimited.
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
</div>
<div>
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
</div>
</div>
</div>
</div>
</details>
</div>
@@ -920,7 +936,11 @@
tailscaleOnly: document.getElementById('deploy-tailscale-only').checked,
mediaPath: mediaPath || null,
plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || null,
customVolumes: customVolumes.length > 0 ? customVolumes : null
customVolumes: customVolumes.length > 0 ? customVolumes : null,
resources: {
cpus: parseFloat(document.getElementById('deploy-cpu-limit').value) || 0,
memory: parseFloat(document.getElementById('deploy-memory-limit').value) || 0,
}
};
// Validate subdomain
+196
View File
@@ -0,0 +1,196 @@
// ========== DOCKER COMPOSE IMPORT ==========
(function() {
injectModal('compose-import-modal', `<div id="compose-import-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>📦 Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
<!-- Step 1: Paste YAML -->
<div id="compose-step-paste">
<div style="margin-bottom: 12px;">
<label class="form-label-accent-sm">Stack Name</label>
<input type="text" id="compose-stack-name" placeholder="my-stack" value="" style="width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
</div>
<div style="margin-bottom: 12px;">
<label class="form-label-accent-sm">docker-compose.yml</label>
<textarea id="compose-yaml" rows="14" placeholder="version: '3'&#10;services:&#10; web:&#10; image: nginx:latest&#10; ports:&#10; - '8080:80'" style="width: 100%; padding: 10px; font-family: monospace; font-size: 0.82rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); resize: vertical;"></textarea>
<div style="margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--muted);">
<input type="file" id="compose-file-upload" accept=".yml,.yaml" style="display: none;" />
<span style="text-decoration: underline;">or upload a file</span>
</label>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="compose-parse-btn" class="btn-accent-solid" style="padding: 8px 20px;">Parse & Preview</button>
<button id="compose-cancel">Cancel</button>
</div>
</div>
<!-- Step 2: Preview -->
<div id="compose-step-preview" style="display: none;">
<div id="compose-preview-content"></div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;">
<button id="compose-deploy-btn" class="btn-accent-solid" style="padding: 8px 20px;">Deploy All</button>
<button id="compose-back-btn">Back</button>
</div>
</div>
<!-- Step 3: Progress -->
<div id="compose-step-progress" style="display: none;">
<div id="compose-progress-content"></div>
</div>
</div>
</div>`);
const modal = document.getElementById('compose-import-modal');
const openBtn = document.getElementById('compose-import-btn');
const cancelBtn = document.getElementById('compose-cancel');
wireModal(modal, cancelBtn);
let parsedData = null;
function showStep(step) {
document.getElementById('compose-step-paste').style.display = step === 'paste' ? '' : 'none';
document.getElementById('compose-step-preview').style.display = step === 'preview' ? '' : 'none';
document.getElementById('compose-step-progress').style.display = step === 'progress' ? '' : 'none';
}
openBtn?.addEventListener('click', () => {
showStep('paste');
parsedData = null;
document.getElementById('compose-yaml').value = '';
document.getElementById('compose-stack-name').value = '';
modal?.classList.add('show');
});
// File upload
document.getElementById('compose-file-upload')?.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => { document.getElementById('compose-yaml').value = reader.result; };
reader.readAsText(file);
});
// Parse
document.getElementById('compose-parse-btn')?.addEventListener('click', async () => {
const yamlStr = document.getElementById('compose-yaml').value.trim();
const stackName = document.getElementById('compose-stack-name').value.trim() || 'stack';
if (!yamlStr) { showNotification('Paste a docker-compose.yml', 'warning'); return; }
const btn = document.getElementById('compose-parse-btn');
const origText = btn.textContent;
btn.textContent = 'Parsing...';
btn.disabled = true;
try {
const data = await postJSON('/api/v1/apps/import-compose', { yaml: yamlStr, stackName });
parsedData = data;
parsedData.stackName = stackName;
renderPreview(data);
showStep('preview');
} catch (e) {
showNotification('Parse failed: ' + e.message, 'error');
} finally {
btn.textContent = origText;
btn.disabled = false;
}
});
function renderPreview(data) {
const container = document.getElementById('compose-preview-content');
let html = '';
if (data.networks && data.networks.length > 0) {
html += `<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${data.networks.map(n => `<code>${escapeHtml(n)}</code>`).join(', ')}</div>`;
}
if (data.volumes && data.volumes.length > 0) {
html += `<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${data.volumes.map(v => `<code>${escapeHtml(v)}</code>`).join(', ')}</div>`;
}
html += `<div style="font-weight: 600; margin-bottom: 8px;">${data.services.length} service(s)</div>`;
html += '<div class="scroll-container" style="max-height: 350px;">';
for (const svc of data.services) {
const borderColor = svc.skip ? 'var(--bad-fg)' : 'var(--border)';
html += `<div style="padding: 10px 14px; border: 1px solid ${borderColor}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`;
html += `<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(svc.name)}`;
if (svc.skip) html += ` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">— skipped: ${escapeHtml(svc.reason)}</span>`;
html += `</div>`;
if (!svc.skip) {
html += `<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(svc.image)}</code></div>`;
if (svc.ports?.length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${svc.ports.map(p => `${p.host}:${p.container}`).join(', ')}</div>`;
if (svc.volumes?.length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${svc.volumes.length}</div>`;
if (Object.keys(svc.environment || {}).length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(svc.environment).length}</div>`;
if (svc.envFileWarning) html += `<div style="font-size: 0.78rem; color: var(--bad-fg);">⚠ ${escapeHtml(svc.envFileWarning)}</div>`;
if (svc.resources?.cpus || svc.resources?.memory) {
const parts = [];
if (svc.resources.cpus) parts.push(`CPU: ${svc.resources.cpus}`);
if (svc.resources.memory) parts.push(`Mem: ${svc.resources.memory}MB`);
html += `<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${parts.join(', ')}</div>`;
}
}
html += '</div>';
}
html += '</div>';
container.innerHTML = html;
}
// Back button
document.getElementById('compose-back-btn')?.addEventListener('click', () => showStep('paste'));
// Deploy
document.getElementById('compose-deploy-btn')?.addEventListener('click', async () => {
if (!parsedData) return;
const btn = document.getElementById('compose-deploy-btn');
btn.textContent = 'Deploying...';
btn.disabled = true;
showStep('progress');
const progressEl = document.getElementById('compose-progress-content');
progressEl.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';
try {
const result = await postJSON('/api/v1/apps/deploy-compose', {
services: parsedData.services,
networks: parsedData.networks,
stackName: parsedData.stackName
});
let html = `<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(result.stackName)}" — Deployment Complete</div>`;
html += '<div class="scroll-container" style="max-height: 350px;">';
for (const r of result.results) {
const icon = r.status === 'deployed' || r.status === 'created' ? '✅' : r.status === 'exists' ? '⚡' : r.status === 'skipped' ? '⏭' : '❌';
html += `<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">`;
html += `${icon} <strong>${escapeHtml(r.name)}</strong> (${r.type}) — ${escapeHtml(r.status)}`;
if (r.error) html += ` <span style="color: var(--bad-fg);">${escapeHtml(r.error)}</span>`;
if (r.subdomain) html += ` → <code>${escapeHtml(r.subdomain)}</code>`;
if (r.reason) html += ` <span style="color: var(--muted);">(${escapeHtml(r.reason)})</span>`;
html += '</div>';
}
html += '</div>';
html += '<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>';
progressEl.innerHTML = html;
document.getElementById('compose-done-btn')?.addEventListener('click', () => {
modal?.classList.remove('show');
if (typeof window.loadServices === 'function') window.loadServices().then(() => { if (typeof window.buildGrid === 'function') window.buildGrid(); });
});
showNotification(`Stack "${result.stackName}" deployed`, 'success');
} catch (e) {
progressEl.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Deployment failed: ${escapeHtml(e.message)}</div>
<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', () => showStep('paste'));
} finally {
btn.textContent = 'Deploy All';
btn.disabled = false;
}
});
})();
+157
View File
@@ -0,0 +1,157 @@
// ========== CONTAINER EXEC / SHELL (WebSocket + xterm.js) ==========
(function() {
injectModal('exec-modal', `<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 8px; padding-bottom: 12px;">
<button id="exec-close">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('exec-modal');
const termEl = document.getElementById('exec-terminal');
const closeBtn = document.getElementById('exec-close');
let term = null;
let ws = null;
let fitAddon = null;
function cleanup() {
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
if (term) { try { term.dispose(); } catch (_) {} term = null; }
fitAddon = null;
termEl.innerHTML = '';
}
function openExec(containerId, containerName) {
cleanup();
document.getElementById('exec-title').textContent = `Terminal — ${containerName || containerId}`;
modal?.classList.add('show');
// Ensure xterm is available
if (typeof Terminal === 'undefined') {
termEl.innerHTML = '<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';
return;
}
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#aeafad',
selectionBackground: '#264f78',
},
scrollback: 5000,
});
// Fit addon
if (typeof FitAddon !== 'undefined') {
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
}
term.open(termEl);
if (fitAddon) {
// Small delay for DOM to settle
setTimeout(() => fitAddon.fit(), 50);
}
// Connect WebSocket
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}/ws/exec/${encodeURIComponent(containerId)}`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
term.writeln('\x1b[32mConnecting...\x1b[0m');
// Send initial resize
if (fitAddon) {
const dims = fitAddon.proposeDimensions();
if (dims) {
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
}
}
};
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'connected') {
term.writeln(`\x1b[32mConnected (${msg.shell})\x1b[0m\r\n`);
return;
}
if (msg.type === 'error') {
term.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`);
return;
}
if (msg.type === 'exit') {
term.writeln('\r\n\x1b[33mSession ended.\x1b[0m');
return;
}
} catch (_) {}
// Plain text
term.write(e.data);
} else {
// Binary data
term.write(new Uint8Array(e.data));
}
};
ws.onclose = () => {
if (term) term.writeln('\r\n\x1b[33mDisconnected.\x1b[0m');
};
ws.onerror = () => {
if (term) term.writeln('\r\n\x1b[31mConnection error.\x1b[0m');
};
// Terminal input → WebSocket
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Handle resize
term.onResize(({ cols, rows }) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
// Re-fit on window resize
const resizeHandler = () => { if (fitAddon) fitAddon.fit(); };
window.addEventListener('resize', resizeHandler);
// Store handler for cleanup
modal._resizeHandler = resizeHandler;
}
closeBtn?.addEventListener('click', () => {
cleanup();
if (modal._resizeHandler) {
window.removeEventListener('resize', modal._resizeHandler);
}
modal?.classList.remove('show');
});
// Also close on backdrop click
modal?.addEventListener('click', (e) => {
if (e.target === modal) {
cleanup();
if (modal._resizeHandler) {
window.removeEventListener('resize', modal._resizeHandler);
}
modal?.classList.remove('show');
}
});
// Export
window.openExecModal = openExec;
})();
+9
View File
@@ -211,6 +211,15 @@
window.updateContainer(s.containerId, s.name, s.id);
};
btnRow.appendChild(updateBtn);
// Terminal exec button (subtle — visible on hover)
const execBtn = el('button', 'exec-btn', '>_');
execBtn.title = 'Open terminal';
execBtn.onclick = (e) => {
e.stopPropagation();
if (window.openExecModal) window.openExecModal(s.containerId, s.name);
};
btnRow.appendChild(execBtn);
}
// Add logs button for services with logPath (native apps)
+228
View File
@@ -0,0 +1,228 @@
// ========== DOCKER RESOURCES (Volumes, Networks, Disk Usage) ==========
(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>🐳 Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
<div class="panel-tabs">
<button class="panel-tab active" data-panel="dr-volumes">Volumes</button>
<button class="panel-tab" data-panel="dr-networks">Networks</button>
<button class="panel-tab" data-panel="dr-disk">Disk Usage</button>
</div>
<!-- Volumes -->
<div id="dr-volumes" class="panel-section active">
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<input type="text" id="dr-vol-name" placeholder="Volume name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
<button id="dr-vol-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
</div>
<div id="dr-vol-list" class="scroll-container" style="max-height: 400px;">
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
</div>
</div>
<!-- Networks -->
<div id="dr-networks" class="panel-section">
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<input type="text" id="dr-net-name" placeholder="Network name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
<select id="dr-net-driver" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
<option value="bridge">bridge</option>
<option value="overlay">overlay</option>
<option value="host">host</option>
</select>
<button id="dr-net-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
</div>
<div id="dr-net-list" class="scroll-container" style="max-height: 400px;">
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
</div>
</div>
<!-- Disk Usage -->
<div id="dr-disk" class="panel-section">
<div id="dr-disk-content">
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="dr-close">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('docker-resources-modal');
const openBtn = document.getElementById('docker-resources-btn');
const closeBtn = document.getElementById('dr-close');
function fmtBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
}
// ===== VOLUMES =====
async function loadVolumes() {
const container = document.getElementById('dr-vol-list');
try {
const data = await getJSON('/api/v1/docker/volumes');
const vols = data.volumes || [];
if (vols.length === 0) {
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">📦</span>No volumes found.</div>';
return;
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
html += '<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 v of vols) {
const isSystem = v.name === 'buildkit' || v.name.length === 64;
html += `<tr style="border-bottom: 1px solid var(--border);">`;
html += `<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(v.name)}">${escapeHtml(v.name.length > 40 ? v.name.substring(0, 37) + '...' : v.name)}</td>`;
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(v.driver)}</td>`;
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(v.scope)}</td>`;
html += `<td style="padding: 6px; text-align: right;">`;
if (!isSystem) {
html += `<button class="dr-vol-del" data-name="${escapeHtml(v.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`;
}
html += `</td></tr>`;
}
html += '</table>';
container.innerHTML = html;
container.querySelectorAll('.dr-vol-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Delete volume "${btn.dataset.name}"? Data will be lost.`)) return;
btn.textContent = '...';
btn.disabled = true;
try {
await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(btn.dataset.name)}?force=true`);
loadVolumes();
} catch (e) {
showNotification('Delete failed: ' + e.message, 'error');
btn.textContent = 'Delete';
btn.disabled = false;
}
});
});
} catch (e) {
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
}
}
document.getElementById('dr-vol-create')?.addEventListener('click', async () => {
const nameInput = document.getElementById('dr-vol-name');
const name = nameInput.value.trim();
if (!name) { showNotification('Enter a volume name', 'warning'); return; }
try {
await postJSON('/api/v1/docker/volumes', { name });
nameInput.value = '';
showNotification(`Volume "${name}" created`, 'success');
loadVolumes();
} catch (e) {
showNotification('Create failed: ' + e.message, 'error');
}
});
// ===== NETWORKS =====
async function loadNetworks() {
const container = document.getElementById('dr-net-list');
try {
const data = await getJSON('/api/v1/docker/networks');
const nets = data.networks || [];
if (nets.length === 0) {
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">🌐</span>No networks found.</div>';
return;
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
html += '<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 n of nets) {
const isSystem = ['bridge', 'host', 'none'].includes(n.name);
html += `<tr style="border-bottom: 1px solid var(--border);">`;
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(n.name)}</td>`;
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(n.driver)}</td>`;
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(n.scope)}</td>`;
html += `<td style="padding: 6px; text-align: center;">${n.containers}</td>`;
html += `<td style="padding: 6px; text-align: right;">`;
if (!isSystem) {
html += `<button class="dr-net-del" data-id="${escapeHtml(n.id)}" data-name="${escapeHtml(n.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`;
}
html += `</td></tr>`;
}
html += '</table>';
container.innerHTML = html;
container.querySelectorAll('.dr-net-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Delete network "${btn.dataset.name}"?`)) return;
btn.textContent = '...';
btn.disabled = true;
try {
await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(btn.dataset.id)}`);
loadNetworks();
} catch (e) {
showNotification('Delete failed: ' + e.message, 'error');
btn.textContent = 'Delete';
btn.disabled = false;
}
});
});
} catch (e) {
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
}
}
document.getElementById('dr-net-create')?.addEventListener('click', async () => {
const nameInput = document.getElementById('dr-net-name');
const driverSelect = document.getElementById('dr-net-driver');
const name = nameInput.value.trim();
if (!name) { showNotification('Enter a network name', 'warning'); return; }
try {
await postJSON('/api/v1/docker/networks', { name, driver: driverSelect.value });
nameInput.value = '';
showNotification(`Network "${name}" created`, 'success');
loadNetworks();
} catch (e) {
showNotification('Create failed: ' + e.message, 'error');
}
});
// ===== DISK USAGE =====
async function loadDiskUsage() {
const container = document.getElementById('dr-disk-content');
try {
const data = await getJSON('/api/v1/docker/disk-usage');
const sections = [
{ label: 'Images', icon: '📀', count: data.images.count, size: data.images.size, reclaimable: data.images.reclaimable },
{ label: 'Containers', icon: '📦', count: data.containers.count, size: data.containers.size, extra: `${data.containers.running} running` },
{ label: 'Volumes', icon: '💾', count: data.volumes.count, size: data.volumes.size, reclaimable: data.volumes.reclaimable },
{ label: 'Build Cache', icon: '🔧', count: data.buildCache.count, size: data.buildCache.size, reclaimable: data.buildCache.reclaimable },
];
let html = `<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 16px;">Total: ${fmtBytes(data.totalSize)}</div>`;
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">';
for (const s of sections) {
html += `<div style="padding: 14px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border);">`;
html += `<div style="font-weight: 600; margin-bottom: 6px;">${s.icon} ${s.label} <span style="color: var(--muted); font-weight: 400; font-size: 0.82rem;">(${s.count})</span></div>`;
html += `<div style="font-size: 1.1rem; font-weight: 600; color: var(--accent);">${fmtBytes(s.size)}</div>`;
if (s.reclaimable > 0) html += `<div style="font-size: 0.78rem; color: var(--muted);">Reclaimable: ${fmtBytes(s.reclaimable)}</div>`;
if (s.extra) html += `<div style="font-size: 0.78rem; color: var(--muted);">${s.extra}</div>`;
html += '</div>';
}
html += '</div>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
}
}
// Modal events
openBtn?.addEventListener('click', () => {
modal?.classList.add('show');
loadVolumes();
});
wireModal(modal, closeBtn);
// Lazy-load tabs
document.querySelector('[data-panel="dr-networks"]')?.addEventListener('click', loadNetworks);
document.querySelector('[data-panel="dr-disk"]')?.addEventListener('click', loadDiskUsage);
})();
+115
View File
@@ -0,0 +1,115 @@
// ========== LIVE DASHBOARD EVENTS (SSE) ==========
(function() {
let es = null;
let reconnectDelay = 1000;
const MAX_RECONNECT = 30000;
function connect() {
if (es) { try { es.close(); } catch (_) {} }
es = new EventSource('/api/v1/events/stream');
es.addEventListener('connected', () => {
reconnectDelay = 1000; // reset backoff
console.log('[SSE] Connected to event stream');
});
// Health status changes → update card dots/badges in real time
es.addEventListener('status-change', (e) => {
try {
const d = JSON.parse(e.data);
if (d.serviceId && typeof window.setBadge === 'function') {
const up = d.status === 'up' || d.status === 'healthy';
window.setBadge(d.serviceId, up, d.responseTime || null);
}
} catch (_) {}
});
// Resource alerts → toast notification
es.addEventListener('resource-alert', (e) => {
try {
const d = JSON.parse(e.data);
const msg = `${d.containerName || d.containerId}: ${d.metric} at ${d.value}% (threshold: ${d.threshold}%)`;
if (typeof showNotification === 'function') {
showNotification(msg, 'warning');
}
} catch (_) {}
});
// Container auto-restart
es.addEventListener('auto-restart', (e) => {
try {
const d = JSON.parse(e.data);
if (typeof showNotification === 'function') {
showNotification(`Container "${d.containerName}" was auto-restarted`, 'info');
}
} catch (_) {}
});
// Update available → show notification dot on Updates button
es.addEventListener('update-available', (e) => {
try {
const d = JSON.parse(e.data);
const updatesBtn = document.getElementById('updates-btn');
if (updatesBtn && !updatesBtn.querySelector('.sse-dot')) {
const dot = document.createElement('span');
dot.className = 'sse-dot';
dot.style.cssText = 'display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;';
updatesBtn.appendChild(dot);
}
if (typeof showNotification === 'function') {
showNotification(`Update available for ${d.containerName || d.containerId}`, 'info');
}
} catch (_) {}
});
// Update start/complete/failed
es.addEventListener('update-complete', (e) => {
try {
const d = JSON.parse(e.data);
if (typeof showNotification === 'function') {
showNotification(`Update completed: ${d.containerName || d.containerId}`, 'success');
}
// Trigger a dashboard refresh
if (typeof window.refreshAll === 'function') window.refreshAll();
} catch (_) {}
});
es.addEventListener('update-failed', (e) => {
try {
const d = JSON.parse(e.data);
if (typeof showNotification === 'function') {
showNotification(`Update failed: ${d.containerName || d.containerId} — ${d.error || 'unknown error'}`, 'error');
}
} catch (_) {}
});
// Incidents
es.addEventListener('incident', (e) => {
try {
const d = JSON.parse(e.data);
if (typeof showNotification === 'function') {
if (d.type === 'created') {
showNotification(`Incident: ${d.message || d.serviceId}`, 'error');
} else if (d.type === 'resolved') {
showNotification(`Resolved: ${d.serviceId || 'incident'}`, 'success');
}
}
} catch (_) {}
});
// Reconnect on error
es.onerror = () => {
es.close();
console.warn(`[SSE] Disconnected, reconnecting in ${reconnectDelay / 1000}s...`);
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT);
};
}
// Start on page load
connect();
// Expose for debugging
window._sseReconnect = connect;
})();
+64 -1
View File
@@ -80,6 +80,52 @@
</div>
</div>
<!-- Email -->
<div class="notification-provider provider-card">
<div class="provider-header">
<label class="checkbox-label">
<input type="checkbox" id="email-enabled" />
<span class="fw-500">Email (SMTP)</span>
</label>
<button id="email-test" class="test-btn btn-xs">Test</button>
</div>
<div id="email-config" style="display: none;">
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">
<div>
<label class="field-label-sm">SMTP Host:</label>
<input type="text" id="email-host" placeholder="smtp.gmail.com" />
</div>
<div>
<label class="field-label-sm">Port:</label>
<input type="number" id="email-port" value="587" placeholder="587" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
<div>
<label class="field-label-sm">Username:</label>
<input type="text" id="email-user" placeholder="user@gmail.com" />
</div>
<div>
<label class="field-label-sm">Password:</label>
<input type="password" id="email-pass" placeholder="app password" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div>
<label class="field-label-sm">From:</label>
<input type="text" id="email-from" placeholder="DashCaddy &lt;noreply@example.com&gt;" />
</div>
<div>
<label class="field-label-sm">To:</label>
<input type="text" id="email-to" placeholder="admin@example.com" />
</div>
</div>
<label style="display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 0.8rem;">
<input type="checkbox" id="email-secure" /> Use TLS (port 465)
</label>
</div>
</div>
<!-- Health Check Settings -->
<h4 class="section-heading">Health Monitoring</h4>
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
@@ -143,7 +189,7 @@
const cancelBtn = document.getElementById('notifications-cancel');
// Provider toggle handlers
['discord', 'telegram', 'ntfy'].forEach(provider => {
['discord', 'telegram', 'ntfy', 'email'].forEach(provider => {
const checkbox = document.getElementById(`${provider}-enabled`);
const config = document.getElementById(`${provider}-config`);
@@ -175,17 +221,23 @@
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false;
document.getElementById('email-enabled').checked = config.providers?.email?.enabled || false;
// Show/hide config sections
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none';
document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none';
document.getElementById('email-config').style.display = config.providers?.email?.enabled ? 'block' : 'none';
// ntfy server URL
if (config.providers?.ntfy?.serverUrl) {
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
}
// email fields
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
// Health check
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
if (config.healthCheck?.intervalMinutes) {
@@ -260,6 +312,16 @@
enabled: document.getElementById('ntfy-enabled').checked,
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
topic: document.getElementById('ntfy-topic').value.trim()
},
email: {
enabled: document.getElementById('email-enabled').checked,
host: document.getElementById('email-host').value.trim(),
port: parseInt(document.getElementById('email-port').value) || 587,
secure: document.getElementById('email-secure').checked,
user: document.getElementById('email-user').value.trim(),
pass: document.getElementById('email-pass').value.trim(),
from: document.getElementById('email-from').value.trim(),
to: document.getElementById('email-to').value.trim()
}
},
events: {
@@ -315,6 +377,7 @@
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
document.getElementById('email-test')?.addEventListener('click', () => testProvider('email'));
// Health check now button
document.getElementById('health-check-now')?.addEventListener('click', async () => {
+31 -12
View File
@@ -226,29 +226,47 @@
async function loadAutoConfig() {
try {
autoContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
// Get running containers to show auto-update toggles
const res = await fetch('/api/v1/stats/containers');
const data = await res.json();
const containers = data.success && data.stats ? data.stats : [];
// Fetch containers and saved auto-update config in parallel
const [containersRes, configRes] = await Promise.all([
fetch('/api/v1/stats/containers'),
fetch('/api/v1/updates/auto-update')
]);
const containersData = await containersRes.json();
const configData = await configRes.json();
const containers = containersData.success && containersData.stats ? containersData.stats : [];
const savedConfig = configData.success && configData.config ? configData.config : {};
if (containers.length === 0) {
autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>';
return;
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<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;">Schedule</th><th style="padding: 8px; text-align: left;">Auto-Rollback</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
let html = '<div style="margin-bottom: 12px; font-size: 0.8rem; color: var(--muted);">Auto-updates run during maintenance window (default 2AM-4AM). Daily = every day, Weekly = Sundays, Monthly = 1st of month.</div>';
html += '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<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;">Schedule</th><th style="padding: 8px; text-align: left;">Window</th><th style="padding: 8px; text-align: left;">Rollback</th><th style="padding: 8px; text-align: left;">Last Run</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
for (const c of containers) {
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
const cid = c.containerId || c.Id;
const saved = savedConfig[cid] || {};
const scheduleVal = saved.enabled ? (saved.schedule || 'weekly') : '';
const rollbackVal = saved.autoRollback !== false;
const windowVal = saved.maintenanceWindow || '';
const lastRun = saved.lastAutoUpdate ? timeAgo(saved.lastAutoUpdate) : 'Never';
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(cid)}">`;
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(name)}</td>`;
html += `<td style="padding: 8px;">
<select class="auto-schedule" data-id="${escapeHtml(cid)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value="">Disabled</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value=""${!scheduleVal ? ' selected' : ''}>Disabled</option>
<option value="daily"${scheduleVal === 'daily' ? ' selected' : ''}>Daily</option>
<option value="weekly"${scheduleVal === 'weekly' ? ' selected' : ''}>Weekly</option>
<option value="monthly"${scheduleVal === 'monthly' ? ' selected' : ''}>Monthly</option>
</select></td>`;
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}" checked /></td>`;
html += `<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(cid)}" value="${escapeHtml(windowVal)}" 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>`;
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}"${rollbackVal ? ' checked' : ''} /></td>`;
html += `<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${lastRun}</td>`;
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(cid)}" data-name="${escapeHtml(name)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
html += '</tr>';
}
@@ -262,13 +280,14 @@
const row = btn.closest('tr');
const schedule = row.querySelector('.auto-schedule').value;
const rollback = row.querySelector('.auto-rollback').checked;
const window = row.querySelector('.auto-window').value.trim();
btn.textContent = 'Saving...';
btn.disabled = true;
try {
const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback })
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback, maintenanceWindow: window || undefined })
});
const d = await r.json();
if (d.success) {
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shiftLine truncated
//# sourceMappingURL=xterm.js.map