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": "^4.22.1",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^7.5.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lru-cache": "^10.4.3", "lru-cache": "^10.4.3",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"png-to-ico": "^2.1.8", "png-to-ico": "^2.1.8",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"validator": "^13.11.0" "validator": "^13.11.0",
"ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.57.1", "eslint": "^8.57.1",
@@ -61,6 +64,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -605,26 +609,6 @@
"url": "https://opencollective.com/eslint" "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": { "node_modules/@eslint/js": {
"version": "8.57.1", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
@@ -1100,6 +1084,30 @@
"node": ">=8" "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": { "node_modules/@istanbuljs/schema": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
@@ -1805,6 +1813,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1894,14 +1903,10 @@
} }
}, },
"node_modules/argparse": { "node_modules/argparse": {
"version": "1.0.10", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true, "license": "Python-2.0"
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
}, },
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
@@ -2188,6 +2193,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "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.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -3087,13 +3094,6 @@
"url": "https://opencollective.com/eslint" "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": { "node_modules/eslint/node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "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" "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": { "node_modules/eslint/node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4795,14 +4782,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.14.2", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^2.0.1"
"esprima": "^4.0.0"
}, },
"bin": { "bin": {
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
@@ -5257,6 +5242,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "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": "^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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+4 -1
View File
@@ -19,14 +19,17 @@
"express": "^4.22.1", "express": "^4.22.1",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^7.5.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lru-cache": "^10.4.3", "lru-cache": "^10.4.3",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"png-to-ico": "^2.1.8", "png-to-ico": "^2.1.8",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"validator": "^13.11.0" "validator": "^13.11.0",
"ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.57.1", "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; 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 { try {
log.info('docker', 'Pulling image', { image: processedTemplate.docker.image }); log.info('docker', 'Pulling image', { image: processedTemplate.docker.image });
await docker.pull(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 initRemoval = require('./removal');
const initTemplates = require('./templates'); const initTemplates = require('./templates');
const initRestore = require('./restore'); const initRestore = require('./restore');
const initCompose = require('./compose');
/** /**
* Apps routes aggregator * Apps routes aggregator
@@ -44,6 +45,7 @@ module.exports = function(ctx) {
router.use(initRemoval(subCtx)); router.use(initRemoval(subCtx));
router.use(initTemplates(subCtx)); router.use(initTemplates(subCtx));
router.use(initRestore(subCtx)); router.use(initRestore(subCtx));
router.use(initCompose(subCtx));
return router; return router;
}; };
+30
View File
@@ -190,6 +190,36 @@ module.exports = function({ docker, log, asyncHandler }) {
success(res, { logs: logs.toString() }); success(res, { logs: logs.toString() });
}, 'container-logs')); }, '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 // Delete container
router.delete('/:id', asyncHandler(async (req, res) => { router.delete('/:id', asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id); 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 express = require('express');
const { validateURL, validateToken } = require('../input-validator'); const { validateURL, validateToken } = require('../input-validator');
const validatorLib = require('validator');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
@@ -32,6 +33,12 @@ module.exports = function({ notification, asyncHandler }) {
enabled: notificationConfig.providers.ntfy?.enabled || false, enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic, configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh' 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, 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)'); 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 // Update enabled state
@@ -101,6 +121,12 @@ module.exports = function({ notification, asyncHandler }) {
...providers.ntfy ...providers.ntfy
}; };
} }
if (providers.email) {
notificationConfig.providers.email = {
...notificationConfig.providers.email,
...providers.email
};
}
} }
// Update events // Update events
@@ -144,6 +170,9 @@ module.exports = function({ notification, asyncHandler }) {
case 'ntfy': case 'ntfy':
result = await notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info'); result = await notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break; break;
case 'email':
result = await notification.sendEmail('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break;
default: default:
throw new ValidationError('Unknown provider'); 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' }); res.json({ success: true, message: 'Auto-update configured' });
}, 'updates-auto-update')); }, '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 // Schedule update
router.post('/updates/schedule/:containerId', asyncHandler(async (req, res) => { router.post('/updates/schedule/:containerId', asyncHandler(async (req, res) => {
const { scheduledTime } = req.body; const { scheduledTime } = req.body;
+5
View File
@@ -52,6 +52,11 @@ process.on('uncaughtException', (error) => {
environment: process.env.NODE_ENV || 'production' 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 // Start feature modules
const resourceMonitor = require('./resource-monitor'); const resourceMonitor = require('./resource-monitor');
const backupManager = require('./backup-manager'); const backupManager = require('./backup-manager');
+12
View File
@@ -66,6 +66,8 @@ const errorLogsRoutes = require('../routes/errorlogs');
const licenseRoutes = require('../routes/license'); const licenseRoutes = require('../routes/license');
const recipesRoutes = require('../routes/recipes'); const recipesRoutes = require('../routes/recipes');
const themesRoutes = require('../routes/themes'); const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events');
// Constants // Constants
const { APP } = require('../constants'); const { APP } = require('../constants');
@@ -419,6 +421,16 @@ async function createApp() {
})); }));
apiRouter.use('/recipes', recipesRoutes(ctx)); apiRouter.use('/recipes', recipesRoutes(ctx));
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler })); 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 // Inline API routes
apiRouter.get('/health', (req, res) => { 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() { start() {
if (this.checking) return; if (this.checking) return;
console.log('[UpdateManager] Starting update checks'); console.log('[UpdateManager] Starting update checks');
this.checking = true; this.checking = true;
// Initial check // Initial check
this.checkForUpdates(); this.checkForUpdates();
// Schedule periodic checks // Schedule periodic checks
this.checkInterval = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL); 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() { stop() {
if (!this.checking) return; if (!this.checking) return;
console.log('[UpdateManager] Stopping update checks'); console.log('[UpdateManager] Stopping update checks');
this.checking = false; this.checking = false;
if (this.checkInterval) { if (this.checkInterval) {
clearInterval(this.checkInterval); clearInterval(this.checkInterval);
this.checkInterval = null; 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'; 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 * Configure auto-update for a container
*/ */
+4
View File
@@ -22,6 +22,7 @@ const bundles = {
JS('core', 'service-infrastructure.js'), JS('core', 'service-infrastructure.js'),
JS('core', 'service-crud.js'), JS('core', 'service-crud.js'),
JS('core', 'service-create.js'), JS('core', 'service-create.js'),
JS('live-events.js'),
], ],
'features.js': [ 'features.js': [
JS('logo-customization.js'), JS('logo-customization.js'),
@@ -37,6 +38,9 @@ const bundles = {
JS('resource-monitor.js'), JS('resource-monitor.js'),
JS('health-check.js'), JS('health-check.js'),
JS('update-management.js'), JS('update-management.js'),
JS('docker-resources.js'),
JS('compose-import.js'),
JS('container-exec.js'),
JS('audit-log.js'), JS('audit-log.js'),
JS('weather.js'), JS('weather.js'),
JS('clock.js'), JS('clock.js'),
+16
View File
@@ -1964,6 +1964,22 @@ button:focus-visible {
cursor: wait; 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 */ /* Credentials (key) button styling */
.creds-btn { .creds-btn {
margin-right: 8px !important; 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; position: fixed; top: 20px; right: 20px;
background: ${k.bg}; color: ${k.fg}; background: ${l.bg}; color: ${l.fg};
padding: 16px 24px; border-radius: 8px; padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3); box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out; z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px; 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: background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+m.r+","+m.g+","+m.b+`, .08), transparent 60%), 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(`+m.r+","+m.g+","+m.b+`, .05), transparent 55%), radial-gradient(1000px 700px at 110% 10%, rgba(`+g.r+","+g.g+","+g.b+`, .05), transparent 55%),
var(--bg); var(--bg);
} }
`:":root."+w+` body { `:":root."+x+` body {
background: background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+m.r+","+m.g+","+m.b+`, .10), transparent 60%), 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(`+m.r+","+m.g+","+m.b+`, .07), transparent 55%), radial-gradient(1000px 700px at 110% -10%, rgba(`+g.r+","+g.g+","+g.b+`, .07), transparent 55%),
var(--bg); 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%); background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
border-color: rgba(0, 0, 0, .15); 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); 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); background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); 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+` { `}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+` {
`,k.forEach(function(D){P[D]&&(m+=" --"+D+": "+P[D]+`; `,l.forEach(function(O){P[O]&&(g+=" --"+O+": "+P[O]+`;
`)}),m+=`} `)}),g+=`}
`,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"> `,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;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3> <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> </button>
</div> </div>
</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;"> <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> <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> </button>
</div> </div>
</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 id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3> <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> </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"> <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-grid">
<div class="token-field"> <div class="token-field">
<label for="${p}-readonly-username">\u{1F4D6} Read-Only (Logs):</label> <label for="${y}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${p}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" /> <input type="text" id="${y}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row"> <div class="token-input-row">
<input type="password" id="${p}-readonly-token" placeholder="Password" autocomplete="off" /> <input type="password" id="${y}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${p}-readonly-token">\u{1F441}</button> <button type="button" class="token-toggle" data-target="${y}-readonly-token">\u{1F441}</button>
</div> </div>
</div> </div>
<div class="token-field"> <div class="token-field">
<label for="${p}-admin-username">\u{1F527} Admin:</label> <label for="${y}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${p}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" /> <input type="text" id="${y}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row"> <div class="token-input-row">
<input type="password" id="${p}-admin-token" placeholder="Password" autocomplete="off" /> <input type="password" id="${y}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${p}-admin-token">\u{1F441}</button> <button type="button" class="token-toggle" data-target="${y}-admin-token">\u{1F441}</button>
</div> </div>
</div> </div>
</div> </div>
<div class="token-status" id="${p}-token-status"></div> <div class="token-status" id="${y}-token-status"></div>
</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} Current: ${h.currentVersion}
New: ${f.updateVersion} New: ${h.updateVersion}
`+(f.updateTitle?`${f.updateTitle} `+(h.updateTitle?`${h.updateTitle}
`:"")+`The DNS server will restart during the update. `:"")+`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?` 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: ${u.downloadLink}`:"",l=u.instructionsLink?` Download: ${p.downloadLink}`:"",c=p.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",` 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 id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3> <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> <button id="dns-settings-save" class="btn-accent">Save</button>
</div> </div>
</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 id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header"> <div class="logs-header">
@@ -324,66 +324,66 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
</div> </div>
</div> </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=` </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(b.timestamp)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(w.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(b.client)}">${escapeHtml(b.client)}</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; ${x?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(b.domain)}">${escapeHtml(b.domain)}</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(b.type)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(w.type)}</span>
<span style="color: ${S}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(b.rcode)}</span> <span style="color: ${C}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(w.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=` `,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="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div> <div>${escapeHtml(C.error)}</div>
</div>`;return}m.innerHTML=` </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;"> <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>Time</span>
<span>Client</span> <span>Client</span>
<span>Domain</span> <span>Domain</span>
<span>Type</span> <span>Type</span>
<span>Status</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);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet 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);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)} Failed to fetch logs: ${escapeHtml(C.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=` </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-shrink: 0;">${M}</div>
<div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(O.text)}</div> <div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(N.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=` `,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;">${x}</div> <div style="flex-shrink: 0;">${E}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(b.text)}</div> <div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(w.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=` `,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="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div> <div>${escapeHtml(C.error)}</div>
</div>`;return}m.innerHTML=` </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;"> <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-shrink: 0; width: 80px;">Stream</span>
<span style="flex: 1;">Log Output</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);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container 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);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)} Failed to fetch logs: ${escapeHtml(C.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>`}}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;">${O}</div> <div style="flex-shrink: 0;">${N}</div>
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div> <div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(C)}</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=` `,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="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div> <div>${escapeHtml(C.error)}</div>
</div>`;return}m.innerHTML=` </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;"> <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> <span style="flex: 1;">Log Output (${C.count} of ${C.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+=` </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);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file 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);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)} Failed to fetch logs: ${escapeHtml(C.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",` </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 id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3> <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> <button id="add-service-create" class="btn-accent">Create Service</button>
</div> </div>
</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)} { </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(p&&(s+=` @blocked not remote_ip 100.64.0.0/10 `;switch(y&&(r+=` @blocked not remote_ip 100.64.0.0/10
`,s+=` respond @blocked "Access denied. Tailscale connection required." 403 `,r+=` respond @blocked "Access denied. Tailscale connection required." 403
`),t){case"letsencrypt":break;case"caddy-managed":s+=` tls internal `),t){case"letsencrypt":break;case"caddy-managed":r+=` tls internal
`;break;case"existing-ca":c&&(s+=` tls { `;break;case"existing-ca":u&&(r+=` tls {
ca ${c} ca ${u}
} }
`);break;case"custom-ca":d&&(s+=` tls { `);break;case"custom-ca":d&&(r+=` tls {
ca ${d} ca ${d}
} }
`);break}if(f&&(s+=` basicauth { `);break}if(h&&(r+=` basicauth {
admin $2a$14$hashed_password_here admin $2a$14$hashed_password_here
} }
`),e&&(s+=` header { `),e&&(r+=` header {
`,s+=` Access-Control-Allow-Origin "*" `,r+=` Access-Control-Allow-Origin "*"
`,s+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" `,r+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,s+=` Access-Control-Allow-Headers "Content-Type, Authorization" `,r+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,s+=` } `,r+=` }
`),a)try{const I=JSON.parse(a);s+=` header { `),s)try{const I=JSON.parse(s);r+=` header {
`,Object.entries(I).forEach(([B,L])=>{s+=` ${B} "${L}" `,Object.entries(I).forEach(([B,L])=>{r+=` ${B} "${L}"
`}),s+=` } `}),r+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(s+=` health_uri ${n} `}catch{console.warn("Invalid JSON in custom headers")}return n&&(r+=` health_uri ${n}
`),s+=` reverse_proxy ${E}:${r} { `),r+=` reverse_proxy ${k}:${a} {
`,u&&u!=="/"&&(s+=` rewrite ${u} `,p&&p!=="/"&&(r+=` rewrite ${p}
`),l&&l!==30&&(s+=` transport http { `),c&&c!==30&&(r+=` transport http {
`,s+=` dial_timeout ${l}s `,r+=` dial_timeout ${c}s
`,s+=` response_header_timeout ${l}s `,r+=` response_header_timeout ${c}s
`,s+=` } `,r+=` }
`),s+=` } `),r+=` }
`,s+=`} `,r+=`}
`,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 `,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: This will:
1. Pull the latest image 1. Pull the latest image
2. Stop the container 2. Stop the container
3. Recreate with same settings 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: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${I.self?.hostname} (${I.self?.ip})</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> <button id="logo-save" class="btn-accent">Save</button>
</div> </div>
</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> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${d}</div> <div><strong>TLD:</strong> ${l}</div>
<div><strong>Certificate Authority:</strong> ${a}</div> <div><strong>Certificate Authority:</strong> ${a}</div>
<div><strong>DNS Server:</strong> ${n}:${e}</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>
</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> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Access Method:</strong> IP:Port only</div> <div><strong>Access Method:</strong> IP:Port only</div>
<div><strong>Default IP:</strong> ${d}</div> <div><strong>Default IP:</strong> ${l}</div>
<div><strong>SSL:</strong> None (HTTP only)</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>
</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> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${d}</div> <div><strong>Domain:</strong> ${l}</div>
<div><strong>SSL:</strong> Let's Encrypt</div> <div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${a}</div> <div><strong>Email:</strong> ${a}</div>
<div><strong>Routing:</strong> ${n==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div> <div><strong>Routing:</strong> ${n==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${e}</div> <div><strong>Example URLs:</strong> ${e}</div>
</div> </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="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 style="font-size: 0.95rem;"><strong>Timezone:</strong> ${c.replace(/_/g," ")}</div>
</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"> <div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2> <h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div> <div id="app-selector-grid" class="app-selector-grid"></div>
@@ -306,6 +306,22 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</div> </div>
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div> <div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
</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> </div>
</details> </details>
</div> </div>
@@ -317,52 +333,52 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button> </button>
</div> </div>
</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-icon">${escapeHtml(o.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(o.name)}</div> <div class="app-option-name">${escapeHtml(o.name)}</div>
<div class="app-option-desc">${escapeHtml(o.description||"")}</div> <div class="app-option-desc">${escapeHtml(o.description||"")}</div>
${g}${I} ${w}${T}
`,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: `,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} Container: ${W.name}
Status: ${J.status} Status: ${W.status}
Port: ${J.primaryPort||"N/A"} Port: ${W.primaryPort||"N/A"}
Would you like to use this existing container? Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container. Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(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}" <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;" /> 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> <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> <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=` `,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(F)} in use by ${escapeHtml(U.conflict?.usedBy||"unknown")}</span> <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 ${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=` `;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: #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;">${q.self?.hostname} (${q.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${U.deviceCount} devices</span> <span style="color: var(--muted); margin-left: 8px;">| ${q.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"}. `: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! 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: ${s.url}`:`${t.name} deployed successfully! URL: ${i.url}`:`${t.name} deployed successfully!
URL: ${s.url}`;s.warning&&(l+=` 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(` \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}: ${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. `);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(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;"> 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="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</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}"> </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;">${v}</span> <span style="font-size: 1.2rem;">${b}</span>
<span style="flex: 1;">${escapeHtml(p.name)}</span> <span style="flex: 1;">${escapeHtml(p.name)}</span>
${o?'<span style="color: var(--success);">\u2713</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;"> <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)} ${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> <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> </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;"> <div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3> <h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -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> <button id="recipe-next" class="btn-accent-solid">Next</button>
</div> </div>
</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} ${r}
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F9EA}")}</div> <div class="app-option-icon">${escapeHtml(e.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div> <div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div> <div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${i} ${s}
`,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=` `,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}" <input type="checkbox" ${e?"checked disabled":"checked"} data-component-id="${a.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" /> style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;"> <div style="flex: 1;">
@@ -447,20 +463,20 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div> </div>
${a.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(a.note)}</div>`:""} ${a.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(a.note)}</div>`:""}
</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> <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||"")}" <input type="text" data-volume-key="${n}" value="${escapeHtml(e.defaultPath||"")}"
placeholder="${escapeHtml(e.defaultPath||"/path")}" 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;" /> 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> <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=` `,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(L.name)}</div> <div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(M.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(L.description||"")}</div> <div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(M.description||"")}</div>
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<strong>Components (${d.length}):</strong> <strong>Components (${l.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;"> <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>':""} \u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")} </div>`).join("")}
</div> </div>
@@ -472,15 +488,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div>`:""} </div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);"> <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> </div>
${L.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(L.network.name)}</code></div>`:""} ${M.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(M.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=` `}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 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> <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="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> <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;"> ${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> <ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(p=>`<li>${escapeHtml(p)}</li>`).join("")}</ul>
</div>`:""} </div>`:""}
</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")} <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);"> </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(i.message)} <strong>Network error:</strong> ${escapeHtml(s.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>`}}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 { .recipe-step {
flex: 1; flex: 1;
text-align: center; text-align: center;
@@ -534,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel { .recipe-step-panel {
min-height: 180px; min-height: 180px;
} }
`,document.head.appendChild(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"> <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> <span class="log-level">ERROR</span>
<div class="log-message"> <div class="log-message">
<strong>${escapeHtml(C.context)}</strong>: ${escapeHtml(C.error)} <strong>${escapeHtml(u.context)}</strong>: ${escapeHtml(u.error)}
${C.details?`<br><small style="opacity: 0.7;">${escapeHtml(C.details)}</small>`:""} ${u.details?`<br><small style="opacity: 0.7;">${escapeHtml(u.details)}</small>`:""}
</div> </div>
</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;"> <div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3> <h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;"> <p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -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> <button id="arr-setup-cancel">Close</button>
</div> </div>
</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> <span style="font-size: 1.1rem;">${p}</span>
<div style="flex: 1;"> <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;"> <div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${o} ${s} ${u} ${o} ${i} ${v}
</div> </div>
</div> </div>
${b(r.status)} ${L(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;"> </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))}/${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>`:""} ${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;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${s}</span> <span style="font-size: 1.1rem;">${i}</span>
<span style="font-weight: 500;">${u}</span> <span style="font-weight: 500;">${v}</span>
<span id="smart-${v}-status" style="margin-left: auto; font-size: 0.75rem;"> <span id="smart-${b}-status" style="margin-left: auto; font-size: 0.75rem;">
${l?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""} ${d?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span> </span>
</div> </div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;"> <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label> <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;" /> style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div> </div>
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label> <label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${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;" /> style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div> </div>
</div> </div>
<button onclick="smartTestConnection('${v}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button> <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 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;"> </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;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span> <span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span> <span style="font-weight: 500;">Plex</span>
${b(r.status)} ${L(r.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
</div> </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;"> </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: ${s.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot; Configured: ${i.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${s.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot; ${i.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${s.plex?"&#10003; Plex":"&#10007; Plex"} ${i.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"};"> </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;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span> <span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span> <span style="font-weight: 500;">Seerr</span>
${b(p.status)} ${L(p.status)}
</div> </div>
${o} ${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;"> </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;">
${v} ${b}
<span>${escapeHtml(p.step)}</span> <span>${escapeHtml(p.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${o};">${escapeHtml(p.details||"")}</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>`}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: ${i};">${r}</div> <div style="font-size: 1.5rem; color: ${s};">${r}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${i};">${p}</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 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;"> </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;">
${s} ${escapeHtml(o.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(o.details||"")}</span> ${i} ${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>",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> <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;"> <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(i.title)}</strong> ${r} <strong>${escapeHtml(s.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(i.count))} items</span> <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{}}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>`}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;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3> <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>
</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 --> <!-- Health Check Settings -->
<h4 class="section-heading">Health Monitoring</h4> <h4 class="section-heading">Health Monitoring</h4>
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> <div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
@@ -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> <button id="notifications-save" class="btn-accent">Save Settings</button>
</div> </div>
</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;"> <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> <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="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-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>
</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;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3> <h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -914,7 +976,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button> <button id="backup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);var 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;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F4CA} Resource Monitor</h3> <h3>\u{1F4CA} Resource Monitor</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -972,11 +1034,11 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="stats-cancel">Close</button> <button id="stats-cancel">Close</button>
</div> </div>
</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="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${a.name||d}</span> <span style="font-weight: 600; flex: 1;">${a.name||l}</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>`:""} ${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> <span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${a.status||"running"}</span>
</div> </div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;"> <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
@@ -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="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;"> <div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(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> </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> </div>
<div> <div>
@@ -997,19 +1059,19 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${r}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${r}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span>
</div> </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> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;"> <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: 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>
</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>`}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;">${d.name||c}</div> <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 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?.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> <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 class="stat-mini-card"><span class="stat-val">${a.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</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>`:""} ${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;"> <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;"> <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 <input type="checkbox" class="alert-enabled" data-container="${c}" ${a.enabled?"checked":""} /> Enabled
</label> </label>
@@ -1045,7 +1107,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<span style="flex: 1;"></span> <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> <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>
</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;"> <div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3> <h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1131,24 +1193,24 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="health-cancel">Close</button> <button id="health-cancel">Close</button>
</div> </div>
</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 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);">Total Checks</span><br><strong>${b.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);">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);">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);">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);">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);">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);">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);">${v.downChecks||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 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>`}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;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span> <span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span>
<span>${S(r.severity)}</span> <span>${N(r.severity)}</span>
</div> </div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</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 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;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3> <h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1220,17 +1282,120 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="updates-cancel">Close</button> <button id="updates-cancel">Close</button>
</div> </div>
</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 </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(g)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;"> <select class="auto-schedule" data-id="${escapeHtml(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="">Disabled</option> <option value=""${U?"":" selected"}>Disabled</option>
<option value="daily">Daily</option> <option value="daily"${U==="daily"?" selected":""}>Daily</option>
<option value="weekly">Weekly</option> <option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
<option value="monthly">Monthly</option> <option value="monthly"${U==="monthly"?" selected":""}>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 </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(` `+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;"> <div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
<h3>\u{1F4DC} Audit Log</h3> <h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle"> <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> <button id="audit-cancel">Close</button>
</div> </div>
</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> <label for="weather-location-input">Location:</label>
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100"> <input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div> <div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -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> <label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label>
</div> </div>
</div> </div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const 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 <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="${w}" cy="${M}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/> <circle cx="${$}" cy="${P}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${j} ${F}
${W} ${J}
<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="${$}" 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="${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="${$}" 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="${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"/> <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="${w}" cy="${M}" r="3" fill="var(--fg)"/> <circle cx="${$}" cy="${P}" 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"> </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}"> <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> <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"> </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> <button id="clock-settings-save">Save</button>
</div> </div>
</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. </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.
${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 ${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;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3> <h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;"> <p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -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> <button id="license-activate" class="btn-accent">Activate</button>
</div> </div>
</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> <div>Code: <code style="font-family: monospace;">${a.code||"***"}</code></div>
${t} ${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> <span style="font-size: 1.1rem;">${e.has(t)?"\u2705":"\u{1F512}"}</span>
<div> <div>
<div style="font-weight: 600; font-size: 0.9rem;">${i.name}</div> <div style="font-weight: 600; font-size: 0.9rem;">${s.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${i.description}</div> <div style="font-size: 0.78rem; color: var(--muted);">${s.description}</div>
</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/themes.css">
<link rel="stylesheet" href="/css/dashboard.css"> <link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/xterm.css">
</head> </head>
<body> <body>
@@ -130,6 +131,7 @@
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button> <button id="view-error-logs" aria-label="View error logs">📋 Logs</button>
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button> <button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</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>
</div> </div>
@@ -231,6 +233,7 @@
<div style="text-align: center; margin: 32px 0; display: flex; justify-content: center; gap: 16px; flex-wrap: wrap;"> <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-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="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> <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> </div>
@@ -545,6 +548,10 @@
<img src="/assets/sami7777-logo.png" alt="samiahmed7777" class="footer-logo"> <img src="/assets/sami7777-logo.png" alt="samiahmed7777" class="footer-logo">
</footer> </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) --> <!-- Bundled JS (built with: npm run build) -->
<script src="/dist/core.js" defer></script> <script src="/dist/core.js" defer></script>
<script src="/dist/features.js" defer></script> <script src="/dist/features.js" defer></script>
+21 -1
View File
@@ -181,6 +181,22 @@
</div> </div>
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div> <div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
</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> </div>
</details> </details>
</div> </div>
@@ -920,7 +936,11 @@
tailscaleOnly: document.getElementById('deploy-tailscale-only').checked, tailscaleOnly: document.getElementById('deploy-tailscale-only').checked,
mediaPath: mediaPath || null, mediaPath: mediaPath || null,
plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || 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 // 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); window.updateContainer(s.containerId, s.name, s.id);
}; };
btnRow.appendChild(updateBtn); 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) // 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>
</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 --> <!-- Health Check Settings -->
<h4 class="section-heading">Health Monitoring</h4> <h4 class="section-heading">Health Monitoring</h4>
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> <div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
@@ -143,7 +189,7 @@
const cancelBtn = document.getElementById('notifications-cancel'); const cancelBtn = document.getElementById('notifications-cancel');
// Provider toggle handlers // Provider toggle handlers
['discord', 'telegram', 'ntfy'].forEach(provider => { ['discord', 'telegram', 'ntfy', 'email'].forEach(provider => {
const checkbox = document.getElementById(`${provider}-enabled`); const checkbox = document.getElementById(`${provider}-enabled`);
const config = document.getElementById(`${provider}-config`); const config = document.getElementById(`${provider}-config`);
@@ -175,17 +221,23 @@
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false; document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false; document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.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 // Show/hide config sections
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none'; 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('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.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 // ntfy server URL
if (config.providers?.ntfy?.serverUrl) { if (config.providers?.ntfy?.serverUrl) {
document.getElementById('ntfy-server').value = 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 // Health check
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false; document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
if (config.healthCheck?.intervalMinutes) { if (config.healthCheck?.intervalMinutes) {
@@ -260,6 +312,16 @@
enabled: document.getElementById('ntfy-enabled').checked, enabled: document.getElementById('ntfy-enabled').checked,
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh', serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
topic: document.getElementById('ntfy-topic').value.trim() 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: { events: {
@@ -315,6 +377,7 @@
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord')); document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram')); document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy')); document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
document.getElementById('email-test')?.addEventListener('click', () => testProvider('email'));
// Health check now button // Health check now button
document.getElementById('health-check-now')?.addEventListener('click', async () => { document.getElementById('health-check-now')?.addEventListener('click', async () => {
+31 -12
View File
@@ -226,29 +226,47 @@
async function loadAutoConfig() { async function loadAutoConfig() {
try { try {
autoContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>'; 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'); // Fetch containers and saved auto-update config in parallel
const data = await res.json(); const [containersRes, configRes] = await Promise.all([
const containers = data.success && data.stats ? data.stats : []; 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) { if (containers.length === 0) {
autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>'; autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>';
return; 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) { for (const c of containers) {
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12); const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
const cid = c.containerId || c.Id; 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 += `<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; font-weight: 500;">${escapeHtml(name)}</td>`;
html += `<td style="padding: 8px;"> 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;"> <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=""${!scheduleVal ? ' selected' : ''}>Disabled</option>
<option value="daily">Daily</option> <option value="daily"${scheduleVal === 'daily' ? ' selected' : ''}>Daily</option>
<option value="weekly">Weekly</option> <option value="weekly"${scheduleVal === 'weekly' ? ' selected' : ''}>Weekly</option>
<option value="monthly">Monthly</option> <option value="monthly"${scheduleVal === 'monthly' ? ' selected' : ''}>Monthly</option>
</select></td>`; </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 += `<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>'; html += '</tr>';
} }
@@ -262,13 +280,14 @@
const row = btn.closest('tr'); const row = btn.closest('tr');
const schedule = row.querySelector('.auto-schedule').value; const schedule = row.querySelector('.auto-schedule').value;
const rollback = row.querySelector('.auto-rollback').checked; const rollback = row.querySelector('.auto-rollback').checked;
const window = row.querySelector('.auto-window').value.trim();
btn.textContent = 'Saving...'; btn.textContent = 'Saving...';
btn.disabled = true; btn.disabled = true;
try { try {
const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, { const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(); const d = await r.json();
if (d.success) { 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