From 6abba43a809347b1ae4527fdc2868a1c30e24bbe Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 01:58:44 -0700 Subject: [PATCH 01/13] fix(self-updater): clear all pending history entries, not just one checkPostUpdateResult() used history.find() which only ever updated a single pending entry. When multiple update attempts stacked up, the extra pending entries stayed stuck in 'pending' forever even though the actual update completed. Switch to filter() + loop to clear all matching entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/self-updater.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index a84ae8a..a2ae0a0 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -313,11 +313,13 @@ class SelfUpdater extends EventEmitter { // Update history const history = this.getUpdateHistory(); - const pending = history.find(h => h.status === 'pending'); - if (pending) { - pending.status = result.success ? 'success' : 'rolled-back'; - pending.duration = result.duration; - if (result.error) pending.error = result.error; + const updated = history.filter(h => h.status === 'pending'); + if (updated.length > 0) { + for (const pending of updated) { + pending.status = result.success ? 'success' : 'rolled-back'; + pending.duration = result.duration; + if (result.error) pending.error = result.error; + } this._saveHistory(history); } From 0460129b32869e96a407413f1a566cb180532ac8 Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 16:27:32 -0700 Subject: [PATCH 02/13] feat(self-updater): allow apiSourceDir override via DASHCADDY_API_SOURCE_DIR env Mirrors the env-var pattern f5fe32b introduced for channel and instanceIdFile. Lets installs that don't follow the default /etc/dashcaddy/sites/dashcaddy-api/ layout (e.g. older deployments under /opt/dashcaddy/) point the auto-updater at the right path without having to patch the constructor call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/self-updater.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index a2ae0a0..665f5ea 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -49,7 +49,7 @@ class SelfUpdater extends EventEmitter { // hostUpdatesDir is the HOST path that maps to updatesDir inside the container. // Used when writing trigger.json so the host-side script can find staging files. hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'), - apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR, + apiSourceDir: options.apiSourceDir || process.env.DASHCADDY_API_SOURCE_DIR || DEFAULTS.API_SOURCE_DIR, frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR, maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10), channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL, From fa40dcff7a82ce2afe25bad7a4aa173cd102e15a Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 17:43:29 -0700 Subject: [PATCH 03/13] chore(release): bump to 1.3.0 v1.2.0 was published as a tarball but its package.json bump was never committed back to git. This release picks up where that gap left off and includes two fixes that v1.2.0 (commit a216dd8) was missing: - 6abba43: clear ALL pending self-update history entries (not just the first), so stuck installs unwind cleanly. - 0460129: allow apiSourceDir to be overridden via the DASHCADDY_API_SOURCE_DIR env var, so installs that don't follow the default /etc/dashcaddy/sites/dashcaddy-api/ layout (e.g. older deployments under /opt/dashcaddy/) can point the auto-updater at the right path without patching the constructor. Without these, instances on the older /opt/dashcaddy/ layout get stuck in a 30-min retry loop where every update attempt fails with 'cp: cannot create directory /etc/dashcaddy/sites/dashcaddy-api/'. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index 9de8770..c0a595a 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -1,6 +1,6 @@ { "name": "dashcaddy-api", - "version": "1.1.5", + "version": "1.3.0", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "main": "server.js", "scripts": { @@ -22,6 +22,7 @@ "compression": "^1.8.1", "cors": "^2.8.6", "dockerode": "^4.0.9", + "dropbox": "^10.34.0", "express": "^4.22.1", "express-rate-limit": "^7.5.1", "helmet": "^8.1.0", @@ -34,7 +35,9 @@ "proper-lockfile": "^4.1.2", "qrcode": "^1.5.3", "sharp": "^0.33.5", + "ssh2-sftp-client": "^11.0.0", "validator": "^13.11.0", + "webdav": "^5.7.1", "ws": "^8.20.0" }, "devDependencies": { From e994ad157e16ca3495994cc703e3c4ad5d610371 Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 17:50:10 -0700 Subject: [PATCH 04/13] fix(systemd): drop MakeDirectory=yes from updater path unit `MakeDirectory=yes` on a `PathChanged=` directive whose target is a file (not a directory) causes systemd to create the watched path as an empty directory on unit start. The container's self-updater then crashes with EISDIR every time it tries to writeFile() the trigger, and the host script never runs. The parent `/opt/dashcaddy/updates/` is already created by the installer/Docker volume, so the flag is redundant and only here as a footgun. Drop it. Reproducer: enable the unit on a fresh system, watch `/opt/dashcaddy/updates/trigger.json` get materialized as a directory within milliseconds of `systemctl start dashcaddy-updater.path`. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/scripts/dashcaddy-updater.path | 1 - 1 file changed, 1 deletion(-) diff --git a/dashcaddy-api/scripts/dashcaddy-updater.path b/dashcaddy-api/scripts/dashcaddy-updater.path index 0506681..f73f2f0 100644 --- a/dashcaddy-api/scripts/dashcaddy-updater.path +++ b/dashcaddy-api/scripts/dashcaddy-updater.path @@ -3,7 +3,6 @@ Description=Watch for DashCaddy update trigger [Path] PathChanged=/opt/dashcaddy/updates/trigger.json -MakeDirectory=yes [Install] WantedBy=multi-user.target From 0cf63231d374c7e9ae315258ca57dddcf9671a2b Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 18:31:28 -0700 Subject: [PATCH 05/13] fix(updater): copy src/, replace routes/ in place instead of nesting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the host-side updater script: 1. The Dockerfile (since f5fe32b) does \`COPY src/ ./src/\`, but the host script never copies src/ from staging into the api source directory. Result: every update fails with "failed to compute cache key: ... '/src': not found". 2. \`cp -rf staging/routes api_source/routes/\` does NOT replace the destination directory — it copies the source dir INTO the destination, producing api_source/routes/routes/. Means new route files end up nested one level deep and never get loaded by server.js, so updates silently regress route handlers even when the build succeeds. Switch to "rm -rf dest && cp -rf src dest" semantics for both routes and src, in all four touch points (deploy + 3 rollback paths). Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/scripts/dashcaddy-update.sh | 39 ++++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/dashcaddy-api/scripts/dashcaddy-update.sh b/dashcaddy-api/scripts/dashcaddy-update.sh index 38a9a1c..01ffe6f 100644 --- a/dashcaddy-api/scripts/dashcaddy-update.sh +++ b/dashcaddy-api/scripts/dashcaddy-update.sh @@ -113,11 +113,12 @@ main() { mkdir -p "$backup_dir" log "Backing up current API files to ${backup_dir}" - # Copy all JS files, package.json, Dockerfile, and routes + # Copy all JS files, package.json, Dockerfile, and tracked subdirs for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml; do [[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true done [[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/" + [[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/" # Save version marker echo "$from_version" > "$backup_dir/VERSION" @@ -128,7 +129,14 @@ main() { for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done - [[ -d "$staging_dir/routes" ]] && cp -rf "$staging_dir/routes" "$api_source_dir/routes/" + if [[ -d "$staging_dir/routes" ]]; then + rm -rf "$api_source_dir/routes" + cp -rf "$staging_dir/routes" "$api_source_dir/routes" + fi + if [[ -d "$staging_dir/src" ]]; then + rm -rf "$api_source_dir/src" + cp -rf "$staging_dir/src" "$api_source_dir/src" + fi # 5. Rebuild container log "Rebuilding container..." @@ -148,7 +156,14 @@ main() { for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done - [[ -d "$backup_dir/routes" ]] && cp -rf "$backup_dir/routes" "$api_source_dir/routes/" + if [[ -d "$backup_dir/routes" ]]; then + rm -rf "$api_source_dir/routes" + cp -rf "$backup_dir/routes" "$api_source_dir/routes" + fi + if [[ -d "$backup_dir/src" ]]; then + rm -rf "$api_source_dir/src" + cp -rf "$backup_dir/src" "$api_source_dir/src" + fi write_result "false" "$version" "$(( $(date +%s) - start_time ))" "Docker build failed" rm -f "${TRIGGER_FILE}.processing" @@ -166,7 +181,14 @@ main() { for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done - [[ -d "$backup_dir/routes" ]] && cp -rf "$backup_dir/routes" "$api_source_dir/routes/" + if [[ -d "$backup_dir/routes" ]]; then + rm -rf "$api_source_dir/routes" + cp -rf "$backup_dir/routes" "$api_source_dir/routes" + fi + if [[ -d "$backup_dir/src" ]]; then + rm -rf "$api_source_dir/src" + cp -rf "$backup_dir/src" "$api_source_dir/src" + fi docker compose build --quiet 2>&1 || docker-compose build --quiet 2>&1 || true docker compose up -d 2>&1 || docker-compose up -d 2>&1 || true @@ -189,7 +211,14 @@ main() { for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done - [[ -d "$backup_dir/routes" ]] && cp -rf "$backup_dir/routes" "$api_source_dir/routes/" + if [[ -d "$backup_dir/routes" ]]; then + rm -rf "$api_source_dir/routes" + cp -rf "$backup_dir/routes" "$api_source_dir/routes" + fi + if [[ -d "$backup_dir/src" ]]; then + rm -rf "$api_source_dir/src" + cp -rf "$backup_dir/src" "$api_source_dir/src" + fi docker compose build --quiet 2>&1 || docker-compose build --quiet 2>&1 || true docker compose up -d 2>&1 || docker-compose up -d 2>&1 || true From 6e47df0d3cc93212ce9fd5f1868dcb11e9a524a5 Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 18:42:23 -0700 Subject: [PATCH 06/13] fix(installer): add src/ deploy + drop MakeDirectory=yes; bump 1.3.1 - install.sh now deploys the src/ directory alongside routes/. Without this, fresh installs of v1.3.0+ produce containers whose Dockerfile references src/ but the directory is missing on the host filesystem, so docker build fails with "/src: not found". - The fallback heredoc that writes /etc/systemd/system/dashcaddy- updater.path drops MakeDirectory=yes for the same reason it was removed from the on-disk unit (e994ad1): systemd creates the watched trigger.json path as an empty directory on unit start, blocking every subsequent update with EISDIR. Bumped to 1.3.1 so the existing v1.3.0 instance auto-updates and picks up these and the host-script fix from 0cf6323. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/package.json | 2 +- dashcaddy-installer/install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index c0a595a..03fff99 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -1,6 +1,6 @@ { "name": "dashcaddy-api", - "version": "1.3.0", + "version": "1.3.1", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "main": "server.js", "scripts": { diff --git a/dashcaddy-installer/install.sh b/dashcaddy-installer/install.sh index 9df436a..1f6559a 100644 --- a/dashcaddy-installer/install.sh +++ b/dashcaddy-installer/install.sh @@ -435,6 +435,7 @@ fetch_source() { cp -f "${api_src}/Dockerfile" "$API_DIR/" cp -f "${api_src}/openapi.yaml" "$API_DIR/" 2>/dev/null || true [[ -d "${api_src}/routes" ]] && cp -rf "${api_src}/routes" "$API_DIR/" + [[ -d "${api_src}/src" ]] && cp -rf "${api_src}/src" "$API_DIR/" ok "API files deployed" # Deploy dashboard files @@ -698,7 +699,6 @@ install_updater_service() { Description=Watch for DashCaddy update trigger [Path] PathChanged=/opt/dashcaddy/updates/trigger.json -MakeDirectory=yes [Install] WantedBy=multi-user.target PATHEOF From f537a0dd25bd002f173f20e159e331b0609afb0b Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 19:14:38 -0700 Subject: [PATCH 07/13] feat: cloud backup destinations + long-term resource history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud backups (Dropbox / WebDAV / SFTP): - backup-manager.js: save + load handlers per provider, credential resolution via credentialManager, destination probe. - routes/backups.js: /credentials/{provider} (masked GET, POST, DELETE), /test-destination, scheduling endpoints. - status/js/backup-restore.js: destination picker, provider-specific credential forms, test button wired to backend probe. - npm deps already present (dropbox 10.34.0, webdav 5.7.1, ssh2-sftp-client 11.0.0). Resource history: - resource-monitor.js: three-tier rollup storage — raw 10s samples (7-day retention), hourly rollups (30-day), daily rollups (365-day). getHistoryByRange() auto-selects the appropriate tier. - routes/monitoring.js: /monitoring/history/:containerId now supports startTime/endTime range mode (legacy ?hours=N still works). - status/js/resource-monitor.js + dashboard.css: "History" tab with range buttons (1h/24h/7d/30d/1y), SVG sparklines for CPU / memory / network. Renderer handles raw and rolled-up shapes. status/dist/features.js rebuilt from source via build.js. Lifted out of wip/cloud-backups-and-history; the half-finished app-deps feature from that branch (frontend calls /api/v1/apps/ check-dependencies but the endpoint doesn't exist) is preserved separately on wip/app-deps for later. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/backup-manager.js | 313 +++++++++++++++++++- dashcaddy-api/resource-monitor.js | 358 +++++++++++++++++++++- dashcaddy-api/routes/backups.js | 110 +++++++ dashcaddy-api/routes/monitoring.js | 23 +- status/css/dashboard.css | 22 ++ status/dist/features.js | 458 ++++++++++++++++------------- status/js/backup-restore.js | 230 ++++++++++++++- status/js/resource-monitor.js | 159 ++++++++++ 8 files changed, 1440 insertions(+), 233 deletions(-) diff --git a/dashcaddy-api/backup-manager.js b/dashcaddy-api/backup-manager.js index b0291e9..1dd8082 100644 --- a/dashcaddy-api/backup-manager.js +++ b/dashcaddy-api/backup-manager.js @@ -543,17 +543,42 @@ class BackupManager extends EventEmitter { switch (destination.type) { case 'local': return await this.saveToLocal(data, destination, backupId); + case 'dropbox': + return await this.saveToDropbox(data, destination, backupId); + case 'webdav': + return await this.saveToWebDAV(data, destination, backupId); + case 'sftp': + return await this.saveToSFTP(data, destination, backupId); default: throw new Error(`Unsupported destination type: ${destination.type}`); } } + /** + * Load encrypted backup blob from a destination location. + * Returns a Buffer that can be passed to decryptBackup/decompressBackup. + */ + async loadFromDestination(location) { + switch (location.type) { + case 'local': + return fs.readFileSync(location.path); + case 'dropbox': + return await this.loadFromDropbox(location); + case 'webdav': + return await this.loadFromWebDAV(location); + case 'sftp': + return await this.loadFromSFTP(location); + default: + throw new Error(`Unsupported destination type: ${location.type}`); + } + } + /** * Save to local filesystem */ async saveToLocal(data, destination, backupId) { const backupDir = destination.path || DEFAULT_BACKUP_DIR; - + // Ensure directory exists if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir, { recursive: true }); @@ -561,9 +586,9 @@ class BackupManager extends EventEmitter { const filename = `${backupId}.backup`; const filepath = path.join(backupDir, filename); - + fs.writeFileSync(filepath, data); - + return { type: 'local', path: filepath, @@ -571,6 +596,257 @@ class BackupManager extends EventEmitter { }; } + // ==================== CLOUD DESTINATIONS ==================== + + /** + * Resolve credentials for a given provider via the credentialManager. + * Throws if required fields are missing. + */ + async _getCloudCredentials(provider) { + const credentialManager = require('./credential-manager'); + const creds = {}; + if (provider === 'dropbox') { + creds.token = await credentialManager.retrieve('backup.dropbox.token'); + if (!creds.token) throw new Error('Dropbox token not configured'); + } else if (provider === 'webdav') { + creds.url = await credentialManager.retrieve('backup.webdav.url'); + creds.username = await credentialManager.retrieve('backup.webdav.username'); + creds.password = await credentialManager.retrieve('backup.webdav.password'); + if (!creds.url || !creds.username || !creds.password) { + throw new Error('WebDAV credentials incomplete (need url, username, password)'); + } + } else if (provider === 'sftp') { + creds.host = await credentialManager.retrieve('backup.sftp.host'); + const portStr = await credentialManager.retrieve('backup.sftp.port'); + creds.port = parseInt(portStr || '22', 10); + creds.username = await credentialManager.retrieve('backup.sftp.username'); + creds.password = await credentialManager.retrieve('backup.sftp.password'); + creds.privateKey = await credentialManager.retrieve('backup.sftp.privateKey'); + if (!creds.host || !creds.username || (!creds.password && !creds.privateKey)) { + throw new Error('SFTP credentials incomplete (need host, username, and either password or privateKey)'); + } + } + return creds; + } + + // ----- Dropbox ----- + + async saveToDropbox(data, destination, backupId) { + const { Dropbox } = require('dropbox'); + const creds = await this._getCloudCredentials('dropbox'); + const dbx = new Dropbox({ accessToken: creds.token }); + + const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, ''); + const remotePath = `${folder}/${backupId}.backup`; + + await dbx.filesUpload({ + path: remotePath, + contents: data, + mode: { '.tag': 'overwrite' }, + autorename: false, + mute: true + }); + + return { + type: 'dropbox', + path: remotePath, + size: data.length + }; + } + + async loadFromDropbox(location) { + const { Dropbox } = require('dropbox'); + const creds = await this._getCloudCredentials('dropbox'); + const dbx = new Dropbox({ accessToken: creds.token }); + const result = await dbx.filesDownload({ path: location.path }); + // Node SDK returns fileBinary on the result + const fileBinary = result.result.fileBinary || result.result.fileBlob; + if (Buffer.isBuffer(fileBinary)) return fileBinary; + return Buffer.from(fileBinary); + } + + // ----- WebDAV ----- + + async saveToWebDAV(data, destination, backupId) { + const { createClient } = require('webdav'); + const creds = await this._getCloudCredentials('webdav'); + const client = createClient(creds.url, { + username: creds.username, + password: creds.password + }); + + const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, ''); + + // Ensure folder exists + try { + const exists = await client.exists(folder); + if (!exists) await client.createDirectory(folder, { recursive: true }); + } catch (_) { + // best-effort + } + + const remotePath = `${folder}/${backupId}.backup`; + await client.putFileContents(remotePath, data, { overwrite: true }); + + return { + type: 'webdav', + path: remotePath, + size: data.length + }; + } + + async loadFromWebDAV(location) { + const { createClient } = require('webdav'); + const creds = await this._getCloudCredentials('webdav'); + const client = createClient(creds.url, { + username: creds.username, + password: creds.password + }); + const data = await client.getFileContents(location.path); + return Buffer.isBuffer(data) ? data : Buffer.from(data); + } + + // ----- SFTP ----- + + async saveToSFTP(data, destination, backupId) { + const SftpClient = require('ssh2-sftp-client'); + const creds = await this._getCloudCredentials('sftp'); + const client = new SftpClient(); + + try { + await client.connect({ + host: creds.host, + port: creds.port, + username: creds.username, + password: creds.password || undefined, + privateKey: creds.privateKey || undefined + }); + + const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, ''); + // Ensure remote dir exists + try { + const exists = await client.exists(folder); + if (!exists) await client.mkdir(folder, true); + } catch (_) { + // best-effort + } + + const remotePath = `${folder}/${backupId}.backup`; + await client.put(Buffer.from(data), remotePath); + + return { + type: 'sftp', + path: remotePath, + size: data.length + }; + } finally { + try { await client.end(); } catch (_) {} + } + } + + async loadFromSFTP(location) { + const SftpClient = require('ssh2-sftp-client'); + const creds = await this._getCloudCredentials('sftp'); + const client = new SftpClient(); + try { + await client.connect({ + host: creds.host, + port: creds.port, + username: creds.username, + password: creds.password || undefined, + privateKey: creds.privateKey || undefined + }); + const buffer = await client.get(location.path); + return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + } finally { + try { await client.end(); } catch (_) {} + } + } + + /** + * Test that a destination is reachable + writable + deletable. + * Performs a small write/read/delete probe. + */ + async testDestination(destination) { + const probeId = `test-${Date.now()}`; + const probeData = Buffer.from(`dashcaddy-test-${probeId}`); + const start = Date.now(); + + try { + const location = await this.saveToDestination(probeData, destination, probeId); + + // Read it back + let readBack = null; + try { + readBack = await this.loadFromDestination(location); + } catch (_) { + // Some providers (e.g. local) we already trust the file system; skip + } + + // Delete the probe + try { + await this._deleteFromDestination(location); + } catch (_) {} + + const elapsed = Date.now() - start; + return { + success: true, + type: destination.type, + elapsedMs: elapsed, + verified: readBack ? readBack.equals(probeData) : null + }; + } catch (error) { + return { + success: false, + type: destination.type, + error: error.message, + elapsedMs: Date.now() - start + }; + } + } + + /** + * Delete a backup from a destination location + */ + async _deleteFromDestination(location) { + if (location.type === 'local') { + if (fs.existsSync(location.path)) fs.unlinkSync(location.path); + return; + } + if (location.type === 'dropbox') { + const { Dropbox } = require('dropbox'); + const creds = await this._getCloudCredentials('dropbox'); + const dbx = new Dropbox({ accessToken: creds.token }); + try { await dbx.filesDeleteV2({ path: location.path }); } catch (_) {} + return; + } + if (location.type === 'webdav') { + const { createClient } = require('webdav'); + const creds = await this._getCloudCredentials('webdav'); + const client = createClient(creds.url, { username: creds.username, password: creds.password }); + try { await client.deleteFile(location.path); } catch (_) {} + return; + } + if (location.type === 'sftp') { + const SftpClient = require('ssh2-sftp-client'); + const creds = await this._getCloudCredentials('sftp'); + const client = new SftpClient(); + try { + await client.connect({ + host: creds.host, + port: creds.port, + username: creds.username, + password: creds.password || undefined, + privateKey: creds.privateKey || undefined + }); + try { await client.delete(location.path); } catch (_) {} + } finally { + try { await client.end(); } catch (_) {} + } + return; + } + } + /** * Verify backup integrity */ @@ -605,9 +881,24 @@ class BackupManager extends EventEmitter { throw new Error(`Backup not found: ${backupId}`); } - // Load backup data - const location = backup.locations[0]; // Use first location - let data = fs.readFileSync(location.path); + // Load backup data — try each destination location until one succeeds + const location = backup.locations[0]; // Primary location + let data; + try { + data = await this.loadFromDestination(location); + } catch (loadErr) { + // Fall back to other locations if available + let recovered = false; + for (let i = 1; i < backup.locations.length; i++) { + try { + data = await this.loadFromDestination(backup.locations[i]); + recovered = true; + console.log(`[BackupManager] Loaded backup from fallback location ${backup.locations[i].type}`); + break; + } catch (_) {} + } + if (!recovered) throw loadErr; + } // Decrypt if needed if (backup.encrypted && options.encryptionKey) { @@ -718,16 +1009,18 @@ class BackupManager extends EventEmitter { for (const backup of toDelete) { try { - // Delete from all locations + // Delete from all locations (local + cloud) for (const location of backup.locations) { - if (location.type === 'local' && fs.existsSync(location.path)) { - fs.unlinkSync(location.path); + try { + await this._deleteFromDestination(location); + } catch (delErr) { + console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message); } } // Remove from history this.history = this.history.filter(b => b.id !== backup.id); - + console.log(`[BackupManager] Deleted old backup: ${backup.id}`); } catch (error) { console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message); diff --git a/dashcaddy-api/resource-monitor.js b/dashcaddy-api/resource-monitor.js index 5c5cf0d..61f9abd 100644 --- a/dashcaddy-api/resource-monitor.js +++ b/dashcaddy-api/resource-monitor.js @@ -13,20 +13,32 @@ const docker = new Docker(); // Configuration const STATS_FILE = process.env.STATS_FILE || path.join(__dirname, 'container-stats.json'); +const STATS_HOURLY_FILE = process.env.STATS_HOURLY_FILE || path.join(__dirname, 'container-stats-hourly.json'); +const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(__dirname, 'container-stats-daily.json'); const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(__dirname, 'alert-config.json'); -const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days default +const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw +const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly +const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily const MONITORING_INTERVAL = parseInt(process.env.MONITORING_INTERVAL || '10000', 10); // 10 seconds +const ROLLUP_HOURLY_INTERVAL = parseInt(process.env.ROLLUP_HOURLY_INTERVAL || String(60 * 60 * 1000), 10); // 1h +const ROLLUP_DAILY_INTERVAL = parseInt(process.env.ROLLUP_DAILY_INTERVAL || String(24 * 60 * 60 * 1000), 10); // 24h class ResourceMonitor extends EventEmitter { constructor() { super(); this.monitoring = false; this.monitoringInterval = null; - this.stats = new Map(); // containerId -> array of stats + this.hourlyRollupTimer = null; + this.dailyRollupTimer = null; + this.stats = new Map(); // containerId -> { name, history: [...] } (raw 10s samples, 7d) + this.hourlyHistory = new Map(); // containerId -> { name, samples: [...] } (hourly avg, 30d) + this.dailyHistory = new Map(); // containerId -> { name, samples: [...] } (daily avg, 365d) this.alerts = new Map(); // containerId -> alert config this.lastAlerts = new Map(); // containerId -> last alert timestamp - + this.loadStats(); + this.loadHourlyStats(); + this.loadDailyStats(); this.loadAlertConfig(); } @@ -42,7 +54,23 @@ class ResourceMonitor extends EventEmitter { console.log('[ResourceMonitor] Starting container monitoring'); this.monitoring = true; this.monitoringInterval = setInterval(() => this.collectStats(), MONITORING_INTERVAL); - + + // Hourly rollup — fires once an hour, computes the previous full hour + this.hourlyRollupTimer = setInterval(() => { + try { this.rollupHourly(); } catch (e) { console.error('[ResourceMonitor] hourly rollup error:', e.message); } + }, ROLLUP_HOURLY_INTERVAL); + + // Daily rollup — schedule first run at the next midnight, then fire every 24h + const now = new Date(); + const nextMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 5); + const msUntilMidnight = nextMidnight.getTime() - now.getTime(); + setTimeout(() => { + try { this.rollupDaily(); } catch (e) { console.error('[ResourceMonitor] daily rollup error:', e.message); } + this.dailyRollupTimer = setInterval(() => { + try { this.rollupDaily(); } catch (e) { console.error('[ResourceMonitor] daily rollup error:', e.message); } + }, ROLLUP_DAILY_INTERVAL); + }, msUntilMidnight); + // Initial collection this.collectStats(); } @@ -52,16 +80,26 @@ class ResourceMonitor extends EventEmitter { */ stop() { if (!this.monitoring) return; - + console.log('[ResourceMonitor] Stopping container monitoring'); this.monitoring = false; - + if (this.monitoringInterval) { clearInterval(this.monitoringInterval); this.monitoringInterval = null; } - + if (this.hourlyRollupTimer) { + clearInterval(this.hourlyRollupTimer); + this.hourlyRollupTimer = null; + } + if (this.dailyRollupTimer) { + clearInterval(this.dailyRollupTimer); + this.dailyRollupTimer = null; + } + this.saveStats(); + this.saveHourlyStats(); + this.saveDailyStats(); } /** @@ -464,12 +502,310 @@ class ResourceMonitor extends EventEmitter { } } + /** + * Aggregate a list of raw samples into a single rollup sample + * @param {Array} samples - Raw stats samples + * @param {string} timestamp - ISO timestamp to use for the rollup bucket + * @returns {Object|null} Aggregated sample, or null if input is empty + */ + _aggregateSamples(samples, timestamp) { + if (!samples || samples.length === 0) return null; + + let cpuSum = 0, cpuMax = 0; + let memSum = 0, memMax = 0; + let memPctSum = 0, memPctMax = 0; + let netRxSum = 0, netTxSum = 0; + let diskRSum = 0, diskWSum = 0; + + for (const s of samples) { + const cpu = s.cpu?.percent || 0; + const memUsage = s.memory?.usage || 0; + const memPct = s.memory?.percent || 0; + cpuSum += cpu; if (cpu > cpuMax) cpuMax = cpu; + memSum += memUsage; if (memUsage > memMax) memMax = memUsage; + memPctSum += memPct; if (memPct > memPctMax) memPctMax = memPct; + netRxSum += s.network?.rxBytes || 0; + netTxSum += s.network?.txBytes || 0; + diskRSum += s.disk?.readBytes || 0; + diskWSum += s.disk?.writeBytes || 0; + } + + const n = samples.length; + return { + timestamp, + sampleCount: n, + cpu: { + avg: Math.round((cpuSum / n) * 100) / 100, + max: Math.round(cpuMax * 100) / 100, + }, + memory: { + avgUsage: Math.round(memSum / n), + maxUsage: memMax, + avgPercent: Math.round((memPctSum / n) * 100) / 100, + maxPercent: Math.round(memPctMax * 100) / 100, + avgUsageMB: Math.round(memSum / n / 1024 / 1024), + maxUsageMB: Math.round(memMax / 1024 / 1024), + }, + network: { + rxBytes: netRxSum, + txBytes: netTxSum, + rxMB: Math.round(netRxSum / 1024 / 1024 * 100) / 100, + txMB: Math.round(netTxSum / 1024 / 1024 * 100) / 100, + }, + disk: { + readBytes: diskRSum, + writeBytes: diskWSum, + readMB: Math.round(diskRSum / 1024 / 1024 * 100) / 100, + writeMB: Math.round(diskWSum / 1024 / 1024 * 100) / 100, + }, + }; + } + + /** + * Combine already-aggregated samples (e.g. hourly buckets) into a single coarser bucket + * @param {Array} samples - Aggregated samples (output of _aggregateSamples) + * @param {string} timestamp - ISO timestamp to use for the rollup bucket + * @returns {Object|null} + */ + _combineAggregated(samples, timestamp) { + if (!samples || samples.length === 0) return null; + + let totalCount = 0; + let cpuWeightedSum = 0, cpuMax = 0; + let memWeightedSum = 0, memMax = 0; + let memPctWeightedSum = 0, memPctMax = 0; + let netRxSum = 0, netTxSum = 0; + let diskRSum = 0, diskWSum = 0; + + for (const s of samples) { + const w = s.sampleCount || 1; + totalCount += w; + cpuWeightedSum += (s.cpu?.avg || 0) * w; + if ((s.cpu?.max || 0) > cpuMax) cpuMax = s.cpu.max; + memWeightedSum += (s.memory?.avgUsage || 0) * w; + if ((s.memory?.maxUsage || 0) > memMax) memMax = s.memory.maxUsage; + memPctWeightedSum += (s.memory?.avgPercent || 0) * w; + if ((s.memory?.maxPercent || 0) > memPctMax) memPctMax = s.memory.maxPercent; + netRxSum += s.network?.rxBytes || 0; + netTxSum += s.network?.txBytes || 0; + diskRSum += s.disk?.readBytes || 0; + diskWSum += s.disk?.writeBytes || 0; + } + + return { + timestamp, + sampleCount: totalCount, + cpu: { + avg: Math.round((cpuWeightedSum / totalCount) * 100) / 100, + max: Math.round(cpuMax * 100) / 100, + }, + memory: { + avgUsage: Math.round(memWeightedSum / totalCount), + maxUsage: memMax, + avgPercent: Math.round((memPctWeightedSum / totalCount) * 100) / 100, + maxPercent: Math.round(memPctMax * 100) / 100, + avgUsageMB: Math.round(memWeightedSum / totalCount / 1024 / 1024), + maxUsageMB: Math.round(memMax / 1024 / 1024), + }, + network: { + rxBytes: netRxSum, + txBytes: netTxSum, + rxMB: Math.round(netRxSum / 1024 / 1024 * 100) / 100, + txMB: Math.round(netTxSum / 1024 / 1024 * 100) / 100, + }, + disk: { + readBytes: diskRSum, + writeBytes: diskWSum, + readMB: Math.round(diskRSum / 1024 / 1024 * 100) / 100, + writeMB: Math.round(diskWSum / 1024 / 1024 * 100) / 100, + }, + }; + } + + /** + * Roll up the previous complete hour of raw samples into a single hourly point. + * Trims hourlyHistory entries older than STATS_HOURLY_RETENTION_DAYS. + */ + rollupHourly() { + const now = new Date(); + // The "previous complete hour" — bucket starts at top of (current_hour - 1) + const bucketStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 1, 0, 0); + const bucketEnd = new Date(bucketStart.getTime() + 60 * 60 * 1000); + const bucketStartMs = bucketStart.getTime(); + const bucketEndMs = bucketEnd.getTime(); + const bucketTimestamp = bucketStart.toISOString(); + + for (const [containerId, data] of this.stats.entries()) { + const samples = data.history.filter(s => { + const t = new Date(s.timestamp).getTime(); + return t >= bucketStartMs && t < bucketEndMs; + }); + if (samples.length === 0) continue; + + const rollup = this._aggregateSamples(samples, bucketTimestamp); + if (!rollup) continue; + + if (!this.hourlyHistory.has(containerId)) { + this.hourlyHistory.set(containerId, { name: data.name, samples: [] }); + } + const entry = this.hourlyHistory.get(containerId); + entry.name = data.name; + // Avoid duplicate buckets if rollup ran twice + if (!entry.samples.find(s => s.timestamp === bucketTimestamp)) { + entry.samples.push(rollup); + } + + // Trim old entries + const cutoff = Date.now() - (STATS_HOURLY_RETENTION_DAYS * 24 * 60 * 60 * 1000); + entry.samples = entry.samples.filter(s => new Date(s.timestamp).getTime() > cutoff); + } + + this.saveHourlyStats(); + } + + /** + * Roll up the previous complete day of hourly samples into a single daily point. + * Trims dailyHistory entries older than STATS_DAILY_RETENTION_DAYS. + */ + rollupDaily() { + const now = new Date(); + // Previous calendar day, midnight to midnight + const bucketStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0); + const bucketEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + const bucketStartMs = bucketStart.getTime(); + const bucketEndMs = bucketEnd.getTime(); + const bucketTimestamp = bucketStart.toISOString(); + + for (const [containerId, data] of this.hourlyHistory.entries()) { + const samples = data.samples.filter(s => { + const t = new Date(s.timestamp).getTime(); + return t >= bucketStartMs && t < bucketEndMs; + }); + if (samples.length === 0) continue; + + const rollup = this._combineAggregated(samples, bucketTimestamp); + if (!rollup) continue; + + if (!this.dailyHistory.has(containerId)) { + this.dailyHistory.set(containerId, { name: data.name, samples: [] }); + } + const entry = this.dailyHistory.get(containerId); + entry.name = data.name; + if (!entry.samples.find(s => s.timestamp === bucketTimestamp)) { + entry.samples.push(rollup); + } + + const cutoff = Date.now() - (STATS_DAILY_RETENTION_DAYS * 24 * 60 * 60 * 1000); + entry.samples = entry.samples.filter(s => new Date(s.timestamp).getTime() > cutoff); + } + + this.saveDailyStats(); + } + + /** + * Get history for a container by time range, auto-selecting the appropriate tier. + * - <= 24h → raw 10s samples + * - 1-30 days → hourly rollups + * - > 30 days → daily rollups + * @param {string} containerId + * @param {number} startTime - epoch ms + * @param {number} endTime - epoch ms + * @returns {{ tier: 'raw'|'hourly'|'daily', samples: Array, unit: string }} + */ + getHistoryByRange(containerId, startTime, endTime) { + const rangeMs = endTime - startTime; + const oneDay = 24 * 60 * 60 * 1000; + const thirtyDays = 30 * oneDay; + + let tier, samples; + if (rangeMs <= oneDay) { + tier = 'raw'; + const data = this.stats.get(containerId); + samples = data ? data.history.filter(s => { + const t = new Date(s.timestamp).getTime(); + return t >= startTime && t <= endTime; + }) : []; + } else if (rangeMs <= thirtyDays) { + tier = 'hourly'; + const data = this.hourlyHistory.get(containerId); + samples = data ? data.samples.filter(s => { + const t = new Date(s.timestamp).getTime(); + return t >= startTime && t <= endTime; + }) : []; + } else { + tier = 'daily'; + const data = this.dailyHistory.get(containerId); + samples = data ? data.samples.filter(s => { + const t = new Date(s.timestamp).getTime(); + return t >= startTime && t <= endTime; + }) : []; + } + + return { tier, samples, unit: tier === 'raw' ? '10s' : tier === 'hourly' ? '1h' : '1d' }; + } + + /** + * Load hourly rollups from disk + */ + loadHourlyStats() { + try { + if (fs.existsSync(STATS_HOURLY_FILE)) { + const data = JSON.parse(fs.readFileSync(STATS_HOURLY_FILE, 'utf8')); + this.hourlyHistory = new Map(Object.entries(data)); + console.log(`[ResourceMonitor] Loaded hourly rollups for ${this.hourlyHistory.size} containers`); + } + } catch (error) { + console.error('[ResourceMonitor] Error loading hourly stats:', error.message); + } + } + + /** + * Save hourly rollups to disk + */ + saveHourlyStats() { + try { + const data = Object.fromEntries(this.hourlyHistory); + fs.writeFileSync(STATS_HOURLY_FILE, JSON.stringify(data, null, 2)); + } catch (error) { + console.error('[ResourceMonitor] Error saving hourly stats:', error.message); + } + } + + /** + * Load daily rollups from disk + */ + loadDailyStats() { + try { + if (fs.existsSync(STATS_DAILY_FILE)) { + const data = JSON.parse(fs.readFileSync(STATS_DAILY_FILE, 'utf8')); + this.dailyHistory = new Map(Object.entries(data)); + console.log(`[ResourceMonitor] Loaded daily rollups for ${this.dailyHistory.size} containers`); + } + } catch (error) { + console.error('[ResourceMonitor] Error loading daily stats:', error.message); + } + } + + /** + * Save daily rollups to disk + */ + saveDailyStats() { + try { + const data = Object.fromEntries(this.dailyHistory); + fs.writeFileSync(STATS_DAILY_FILE, JSON.stringify(data, null, 2)); + } catch (error) { + console.error('[ResourceMonitor] Error saving daily stats:', error.message); + } + } + /** * Export stats for backup */ exportStats() { return { stats: Object.fromEntries(this.stats), + hourlyHistory: Object.fromEntries(this.hourlyHistory), + dailyHistory: Object.fromEntries(this.dailyHistory), alerts: Object.fromEntries(this.alerts), exportedAt: new Date().toISOString() }; @@ -482,10 +818,18 @@ class ResourceMonitor extends EventEmitter { if (data.stats) { this.stats = new Map(Object.entries(data.stats)); } + if (data.hourlyHistory) { + this.hourlyHistory = new Map(Object.entries(data.hourlyHistory)); + } + if (data.dailyHistory) { + this.dailyHistory = new Map(Object.entries(data.dailyHistory)); + } if (data.alerts) { this.alerts = new Map(Object.entries(data.alerts)); } this.saveStats(); + this.saveHourlyStats(); + this.saveDailyStats(); this.saveAlertConfig(); } } diff --git a/dashcaddy-api/routes/backups.js b/dashcaddy-api/routes/backups.js index 7b0361a..739c013 100644 --- a/dashcaddy-api/routes/backups.js +++ b/dashcaddy-api/routes/backups.js @@ -42,5 +42,115 @@ module.exports = function({ backupManager, asyncHandler }) { success(res, { result }); }, 'backups-restore')); + // ==================== CLOUD DESTINATIONS ==================== + + // Test a destination (write+read+delete probe) + router.post('/backups/test-destination', asyncHandler(async (req, res) => { + const destination = req.body; + if (!destination || !destination.type) { + const { ValidationError } = require('../errors'); + throw new ValidationError('destination.type is required'); + } + const result = await backupManager.testDestination(destination); + success(res, result); + }, 'backups-test-destination')); + + // Get cloud credentials (masked) for a provider + // Provider: dropbox | webdav | sftp + router.get('/backups/credentials/:provider', asyncHandler(async (req, res) => { + const credentialManager = require('../credential-manager'); + const provider = req.params.provider; + if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { + const { ValidationError } = require('../errors'); + throw new ValidationError('Invalid provider'); + } + + const mask = (val) => val ? '***' : null; + let creds = {}; + if (provider === 'dropbox') { + const token = await credentialManager.retrieve('backup.dropbox.token'); + creds = { token: mask(token) }; + } else if (provider === 'webdav') { + creds = { + url: (await credentialManager.retrieve('backup.webdav.url')) || null, + username: (await credentialManager.retrieve('backup.webdav.username')) || null, + password: mask(await credentialManager.retrieve('backup.webdav.password')) + }; + } else if (provider === 'sftp') { + creds = { + host: (await credentialManager.retrieve('backup.sftp.host')) || null, + port: (await credentialManager.retrieve('backup.sftp.port')) || '22', + username: (await credentialManager.retrieve('backup.sftp.username')) || null, + password: mask(await credentialManager.retrieve('backup.sftp.password')), + privateKey: mask(await credentialManager.retrieve('backup.sftp.privateKey')) + }; + } + success(res, { provider, credentials: creds }); + }, 'backups-credentials-get')); + + // Save cloud credentials for a provider + router.post('/backups/credentials/:provider', asyncHandler(async (req, res) => { + const credentialManager = require('../credential-manager'); + const { ValidationError } = require('../errors'); + const provider = req.params.provider; + + if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { + throw new ValidationError('Invalid provider'); + } + + const body = req.body || {}; + const storeIfPresent = async (key, val) => { + if (val !== undefined && val !== null && val !== '' && val !== '***') { + await credentialManager.store(key, String(val)); + } + }; + + if (provider === 'dropbox') { + if (!body.token || body.token === '***') { + const existing = await credentialManager.retrieve('backup.dropbox.token'); + if (!existing) { + throw new ValidationError('Dropbox token required'); + } + } else { + await credentialManager.store('backup.dropbox.token', body.token); + } + } else if (provider === 'webdav') { + await storeIfPresent('backup.webdav.url', body.url); + await storeIfPresent('backup.webdav.username', body.username); + await storeIfPresent('backup.webdav.password', body.password); + } else if (provider === 'sftp') { + await storeIfPresent('backup.sftp.host', body.host); + await storeIfPresent('backup.sftp.port', body.port); + await storeIfPresent('backup.sftp.username', body.username); + await storeIfPresent('backup.sftp.password', body.password); + await storeIfPresent('backup.sftp.privateKey', body.privateKey); + } + + success(res, { message: `${provider} credentials saved` }); + }, 'backups-credentials-set')); + + // Delete cloud credentials for a provider + router.delete('/backups/credentials/:provider', asyncHandler(async (req, res) => { + const credentialManager = require('../credential-manager'); + const { ValidationError } = require('../errors'); + const provider = req.params.provider; + + if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { + throw new ValidationError('Invalid provider'); + } + + const keys = { + dropbox: ['backup.dropbox.token'], + webdav: ['backup.webdav.url', 'backup.webdav.username', 'backup.webdav.password'], + sftp: ['backup.sftp.host', 'backup.sftp.port', 'backup.sftp.username', 'backup.sftp.password', 'backup.sftp.privateKey'] + }; + + for (const k of keys[provider]) { + try { await credentialManager.delete(k); } catch (_) {} + } + + success(res, { message: `${provider} credentials deleted` }); + }, 'backups-credentials-delete')); + return router; }; diff --git a/dashcaddy-api/routes/monitoring.js b/dashcaddy-api/routes/monitoring.js index 2824aea..760d042 100644 --- a/dashcaddy-api/routes/monitoring.js +++ b/dashcaddy-api/routes/monitoring.js @@ -31,11 +31,28 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log }) { success(res, { stats }); }, 'monitoring-stats-container')); - // Get historical stats + // Get historical stats — supports either ?hours=24 (legacy raw) OR ?startTime=...&endTime=... + // (range mode auto-selects raw / hourly / daily tier) router.get('/monitoring/history/:containerId', asyncHandler(async (req, res) => { + const containerId = req.params.containerId; + + // Range mode (preferred) + if (req.query.startTime && req.query.endTime) { + const startTime = parseInt(req.query.startTime, 10); + const endTime = parseInt(req.query.endTime, 10); + if (Number.isNaN(startTime) || Number.isNaN(endTime) || startTime >= endTime) { + const { ValidationError } = require('../errors'); + throw new ValidationError('Invalid startTime/endTime'); + } + const result = resourceMonitor.getHistoryByRange(containerId, startTime, endTime); + success(res, { ...result, startTime, endTime }); + return; + } + + // Legacy hours-based mode (raw samples only) const hours = parseInt(req.query.hours) || 24; - const history = resourceMonitor.getHistoricalStats(req.params.containerId, hours); - success(res, { history, hours }); + const history = resourceMonitor.getHistoricalStats(containerId, hours); + success(res, { history, hours, tier: 'raw', samples: history, unit: '10s' }); }, 'monitoring-history')); // Get aggregated stats diff --git a/status/css/dashboard.css b/status/css/dashboard.css index 0ae51e5..892a422 100644 --- a/status/css/dashboard.css +++ b/status/css/dashboard.css @@ -2815,6 +2815,28 @@ button:focus-visible { display: block; } +/* Range selector buttons (resource monitor history) */ +.stats-range-btn { + padding: 4px 10px; + background: var(--card-base); + border: 1px solid var(--border); + color: var(--muted); + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; +} + +.stats-range-btn:hover { + border-color: var(--accent); + color: var(--fg); +} + +.stats-range-btn.active { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: var(--accent); + color: var(--accent); +} + /* Status badges (Health and Updates) */ .status-badge { display: inline-flex; diff --git a/status/dist/features.js b/status/dist/features.js index d47f724..ba52e5c 100644 --- a/status/dist/features.js +++ b/status/dist/features.js @@ -90,44 +90,44 @@ - `);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? + `);const w=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),z=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),C=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),$=document.getElementById("logo-single-upload"),L=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),k=document.getElementById("logo-upload-single"),f=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),E=document.querySelector(".top-row"),R=document.getElementById("dashboard-title"),O=DC.NAME;let D=null,N=null,M=null,H="left",S=O;C?.addEventListener("change",()=>{C.checked?(P.style.display="none",$.style.display="",D=null,N=null):(P.style.display="flex",$.style.display="none",M=null)});function T(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=t=>e(t.target.result),a.readAsDataURL(n)}L?.addEventListener("change",n=>{T(n.target.files[0],e=>{D=e,B.src=e,I.textContent="New dark logo ready to save"})}),g?.addEventListener("change",n=>{T(n.target.files[0],e=>{N=e,z.src=e,I.textContent="New light logo ready to save"})}),k?.addEventListener("change",n=>{T(n.target.files[0],e=>{M=e,B.src=e,z.src=e,I.textContent="New logo ready to save (both themes)"})});function b(n){E.setAttribute("data-logo-pos",n),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===n?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===n?"white":"var(--fg)"})}function u(n){S=n||O,document.title=S;const e=document.querySelector(".dashboard-title");e&&(e.textContent=S)}async function y(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(f.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,z.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(f.src=e.customLogo,x.src=e.customLogo,B.src=e.customLogo,z.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(H=e.position,b(e.position)),e.dashboardTitle&&u(e.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{H=n.dataset.pos,b(H)})}),document.getElementById("brand")?.addEventListener("click",()=>{D=null,N=null,M=null,L&&(L.value=""),g&&(g.value=""),k&&(k.value=""),C&&(C.checked=!1),P.style.display="flex",$.style.display="none",B.src=f.src,z.src=x.src;const n=f.src.includes("custom-logo")||x.src.includes("custom-logo");I.textContent=n?"Using custom logo":"Using default logos",b(H),R.value=S,w.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=R.value.trim()||O,e={position:H,dashboardTitle:n};C?.checked&&M?(e.dataDark=M,e.dataLight=M):(D&&(e.dataDark=D),N&&(e.dataLight=N));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const t=await a.json(),s="?t="+Date.now();t.pathDark&&(f.src=t.pathDark+s,B.src=t.pathDark+s),t.pathLight&&(x.src=t.pathLight+s,z.src=t.pathLight+s),b(H),u(n),w.classList.remove("show")}else{const t=await a.json();showNotification("Failed to save: "+t.error,"error")}}catch(n){showNotification("Error saving: "+n.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? -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='
';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+=` +This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(f.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",z.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",D=null,N=null,M=null,R.value=O,u(O),H="left",b("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),s=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),s&&(s.textContent="Using DashCaddy favicon"),r=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(w,document.getElementById("logo-cancel"));const c=document.getElementById("favicon-preview"),o=document.getElementById("favicon-status"),l=document.getElementById("favicon-upload"),v=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(v.rel="icon",v.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(v));async function p(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const e=await n.json();e.customFavicon&&(v.href=e.customFavicon+"?t="+Date.now(),c.src=e.customFavicon+"?t="+Date.now(),o.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}l?.addEventListener("change",n=>{const e=n.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),l.value="";return}const a=new FileReader;a.onload=t=>{r=t.target.result,c.src=r,o.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(n.ok){const e=await n.json();v.href=e.path+"?t="+Date.now(),c.src=e.path+"?t="+Date.now(),o.textContent="Using custom favicon",r=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),p(),y();const m=document.getElementById("settings-timezone");m&&(new MutationObserver(()=>{w.classList.contains("show")&&m.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(m,e)})()}).observe(w,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=m.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const t=await a.json();t.timezone=e,t.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(w,B){const z=Intl.supportedValuesOf("timeZone"),I=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";w.innerHTML="";for(const C of z){const P=document.createElement("option");P.value=C,P.textContent=C.replace(/_/g," "),C===I&&(P.selected=!0),w.appendChild(P)}},(function(){let w="homelab",B=null;async function z(){try{const T=await fetch("/api/v1/config");if(T.ok&&(B=await T.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(T){console.warn("Could not fetch server config, checking localStorage fallback:",T.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}z();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function C(S){document.querySelectorAll(".setup-step").forEach(b=>{b.style.display="none"});const T=document.getElementById(S);T&&(T.style.display="block")}function P(){const S=document.getElementById("setup-summary-content");if(!S)return;let T='
';if(w==="homelab"){const u=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",c=document.getElementById("setup-dns-ip")?.value?.trim()||"",o=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;T+=`

Home Lab Configuration

-
TLD: ${l}
-
Certificate Authority: ${a}
-
DNS Server: ${n}:${e}
-
Example URLs: https://uptime${l}, https://nextcloud${l}
+
TLD: ${u}
+
Certificate Authority: ${y}
+
DNS Server: ${c}:${o}
+
Example URLs: https://uptime${u}, https://nextcloud${u}
- `}else if(f==="simple"){const l=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";I+=` + `}else if(w==="simple"){const u=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";T+=`

Simple Setup

Access Method: IP:Port only
-
Default IP: ${l}
+
Default IP: ${u}
SSL: None (HTTP only)
-
Example URLs: http://${l}:8080, http://${l}:3000
+
Example URLs: http://${u}:8080, http://${u}:3000
- `}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+=` + `}else if(w==="public"){const u=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",c=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",o=c==="subdirectory"?`https://${u}/sonarr, https://${u}/grafana`:`https://sonarr.${u}, https://grafana.${u}`;T+=`

Public Server

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

Choose an App

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

Deploy Recipe

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

\u{1F4CB} Error Logs

Loading error logs...
');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='
Loading error logs...
';try{const h=await(await fetch("/api/v1/error-logs")).json();h.success&&h.logs?h.logs.length===0?E.innerHTML='
\u2705 No errors logged! Everything is working smoothly.
':E.innerHTML=h.logs.map(u=>` + `,document.head.appendChild(T),f()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const w=document.getElementById("reload-caddy-top"),B=w.textContent;try{w.textContent="\u23F3 Reloading...",w.disabled=!0;const z=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await z.json();if(z.ok&&I.success)w.textContent="\u2705 Reloaded!",setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(z){w.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${z.message}`,"error"),setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'

\u{1F4CB} Error Logs

Loading error logs...
');const w=document.getElementById("error-log-modal"),B=document.getElementById("error-log-content"),z=document.getElementById("view-error-logs"),I=document.getElementById("error-log-refresh"),C=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function $(){B.innerHTML='
Loading error logs...
';try{const k=await(await fetch("/api/v1/error-logs")).json();k.success&&k.logs?k.logs.length===0?B.innerHTML='
\u2705 No errors logged! Everything is working smoothly.
':B.innerHTML=k.logs.map(f=>`
- ${new Date(u.timestamp).toLocaleString()} + ${new Date(f.timestamp).toLocaleString()} ERROR
- ${escapeHtml(u.context)}: ${escapeHtml(u.error)} - ${u.details?`
${escapeHtml(u.details)}`:""} + ${escapeHtml(f.context)}: ${escapeHtml(f.error)} + ${f.details?`
${escapeHtml(f.details)}`:""}
- `).join(""):E.innerHTML='
\u274C Failed to load error logs
'}catch(m){E.innerHTML=`
\u274C Error loading logs: ${escapeHtml(m.message)}
`}}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",`
+ `).join(""):B.innerHTML='
\u274C Failed to load error logs
'}catch(g){B.innerHTML=`
\u274C Error loading logs: ${escapeHtml(g.message)}
`}}async function L(){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),$()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}z?.addEventListener("click",()=>{w.classList.add("show"),$()}),I?.addEventListener("click",$),C?.addEventListener("click",L),wireModal(w,P)})(),(function(){injectModal("arr-setup-modal",`

\u{1F3AC} Smart Arr Connect

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

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

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

-
`;for(const s of e.libraries){const r=s.type==="movie"?"\u{1F3AC}":s.type==="show"?"\u{1F4FA}":"\u{1F3B5}";t+=`
- ${r} ${escapeHtml(s.title)} - ${escapeHtml(String(s.count))} items -
`}t+="
",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",`
+ ${escapeHtml(p.details||"")} +
`}f.innerHTML=r,setTimeout(()=>u(v),500)}catch(l){f.innerHTML=`
Connection error: ${escapeHtml(l.message)}
`}}function u(c){M("results");const o=c.summary||{},l=o.failed===0&&o.succeeded>0,v=l?"var(--ok-fg)":"#f39c12",r=l?"✓":"⚠",p=l?"All Connected!":`${escapeHtml(String(o.succeeded))}/${escapeHtml(String(o.totalSteps))} Steps Succeeded`;let m=`
+
${r}
+
${p}
+
${escapeHtml(String(o.succeeded))} succeeded, ${escapeHtml(String(o.failed))} failed
+
`;m+='
';for(const n of c.steps||[]){const e=n.status==="success"?'':'';m+=`
+ ${e} ${escapeHtml(n.step)} ${escapeHtml(n.details||"")} +
`}m+="
",x.innerHTML=m,R.style.display=o.failed>0?"block":"none",c.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&y()}async function y(){try{const o=await(await fetch("/api/v1/plex/libraries")).json();if(o.success&&o.libraries?.length>0){let l=`
+

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

+
`;for(const v of o.libraries){const r=v.type==="movie"?"\u{1F3AC}":v.type==="show"?"\u{1F4FA}":"\u{1F3B5}";l+=`
+ ${r} ${escapeHtml(v.title)} + ${escapeHtml(String(v.count))} items +
`}l+="
",E.innerHTML=l,E.style.display="block"}}catch{}}B?.addEventListener("click",()=>{w.classList.add("show"),E.style.display="none",S()}),wireModal(w,z),I?.addEventListener("click",b),R?.addEventListener("click",b)})(),(function(){injectModal("notifications-modal",`

\u{1F514} Notification Settings

@@ -884,15 +884,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
-
`);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` +
`);const w=document.getElementById("notifications-modal"),B=document.getElementById("manage-notifications"),z=document.getElementById("notifications-save"),I=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(f=>{const x=document.getElementById(`${f}-enabled`),E=document.getElementById(`${f}-config`);x?.addEventListener("change",()=>{E.style.display=x.checked?"block":"none"})});const C=document.getElementById("health-check-enabled"),P=document.getElementById("health-check-config");C?.addEventListener("change",()=>{P.style.opacity=C.checked?"1":"0.5"});async function $(){try{const x=await(await fetch("/api/v1/notifications/config")).json();if(x.success){const E=x.config;document.getElementById("notifications-enabled").checked=E.enabled,document.getElementById("discord-enabled").checked=E.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=E.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=E.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=E.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=E.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=E.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=E.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=E.providers?.email?.enabled?"block":"none",E.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=E.providers.ntfy.serverUrl),E.providers?.email?.host&&(document.getElementById("email-host").value=E.providers.email.host),E.providers?.email?.from&&(document.getElementById("email-from").value=E.providers.email.from),document.getElementById("health-check-enabled").checked=E.healthCheck?.enabled||!1,E.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=E.healthCheck.intervalMinutes),E.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(E.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=E.events?.containerDown!==!1,document.getElementById("event-container-up").checked=E.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=E.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=E.events?.deploymentFailed!==!1}}catch(f){console.error("Failed to load notification config:",f)}}async function L(){try{const x=await(await fetch("/api/v1/notifications/history?limit=10")).json(),E=document.getElementById("notification-history");x.success&&x.history?.length>0?E.innerHTML=x.history.map(R=>{const O=new Date(R.timestamp).toLocaleString();return`
- ${O.type==="success"?"\u2713":O.type==="error"?"\u2717":"\u2139"} + ${R.type==="success"?"\u2713":R.type==="error"?"\u2717":"\u2139"}
-
${escapeHtml(O.title)}
-
${A}
+
${escapeHtml(R.title)}
+
${O}
- `}).join(""):x.innerHTML='
No notifications yet
'}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 + `}).join(""):E.innerHTML='
No notifications yet
'}catch(f){console.error("Failed to load notification history:",f)}}async function g(){try{const f={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}},E=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)})).json();E.success?(showNotification("Notification settings saved","success",3e3),w.classList.remove("show")):showNotification(`Failed to save: ${E.error}`,"error",3e3)}catch(f){showNotification(`Error: ${f.message}`,"error",3e3)}}async function k(f){try{const E=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:f})})).json();E.success?showNotification(`Test ${f} notification sent!`,"success",3e3):showNotification(`Test failed: ${E.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const x=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();x.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.lastCheck).toLocaleString()} (${x.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(f){showNotification(`Error: ${f.message}`,"error",3e3)}}),B?.addEventListener("click",()=>{w.classList.add("show"),$(),L()}),z?.addEventListener("click",g),wireModal(w,I)})(),(function(){document.addEventListener("click",w=>{const B=w.target.closest(".panel-tab");if(!B)return;const z=B.dataset.panel;if(!z)return;const I=B.closest(".panel-tabs"),C=I.closest(".weather-modal-content");I.querySelectorAll(".panel-tab").forEach($=>$.classList.remove("active")),B.classList.add("active"),C.querySelectorAll(".panel-section").forEach($=>$.classList.remove("active"));const P=C.querySelector("#"+z);P&&P.classList.add("active")})})(),(function(){var w=["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 B(){for(var e={},a=0;a

\u{1F4BE} Backup & Restore

- `);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=' 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='
Legacy format (v'+escapeHtml(t.version)+")
";s+='
',t.services?.length&&(s+='\u{1F4CB} '+t.services.length+" services"),t.customApps?.length&&(s+='\u{1F4E6} '+t.customApps.length+" custom apps"),t.theme&&(s+='\u{1F3A8} Theme: '+escapeHtml(t.theme)+""),t.userThemes&&(s+='\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes"),s+="
",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='
Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")
";s+='
Server Config
',s+='
';for(var b in p.preview.files){var o=p.preview.files[b],i=o.action==="create"?"\u{1F195}":"\u{1F4DD}";s+=''+i+" "+escapeHtml(o.description)+""}s+="
",p.preview.serviceCount&&(s+='
'+p.preview.serviceCount+" services
"),p.preview.themeCount&&(s+='
\u{1F3A8} '+p.preview.themeCount+" custom themes
"),p.preview.browserStateCount&&(s+='
Browser Preferences
',s+='
\u{1F5A5}\uFE0F '+p.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)
"),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 srgb, var(--bad-fg) 15%, transparent)",N.style.border="1px solid var(--bad-fg)",x.style.display="none"}}}),A?.addEventListener("click",async function(){if(L&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){A.disabled=!0,A.innerHTML=' Restoring...';try{if(k(L)){S(L),N.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",N.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",N.style.border="1px solid var(--ok-fg)",N.style.display="block",setTimeout(function(){location.reload()},2e3),A.disabled=!1,A.innerHTML="\u26A1 Restore Everything";return}var a=document.getElementById("backup-reload-caddy")?.checked??!0,n=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:L,options:{reloadCaddy:a}})}),e=await n.json(),t=0;if(L.browserState&&(t=M(L.browserState)),e.success){var s="\u2705 "+e.message;t>0&&(s+='
'+t+" browser settings restored"),e.results.caddyReloaded&&(s+='
Caddy configuration reloaded'),N.innerHTML=s,N.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",N.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else N.innerHTML="\u26A0\uFE0F "+escapeHtml(e.message),t>0&&(N.innerHTML+='
'+t+" browser settings were restored"),e.results?.errors?.length>0&&(N.innerHTML+="
"+e.results.errors.map(function(r){return escapeHtml(r.file)+": "+escapeHtml(r.error)}).join(", ")+""),N.style.background="color-mix(in srgb, #f39c12 15%, transparent)",N.style.border="1px solid #f39c12";N.style.display="block"}catch(r){N.innerHTML="\u274C Restore failed: "+escapeHtml(r.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)"}A.disabled=!1,A.innerHTML="\u26A1 Restore Everything"}});async function g(){if(z)try{var a=await fetch("/api/v1/backups/config"),n=await a.json();if(!n.success)throw new Error(n.error||"Failed to load config");var e=n.config?.backups||{},t=Object.keys(e)[0],s=t?e[t]:null,r='
';r+='

\u23F0 Backup Schedule

',r+='
',r+='
',r+='
",r+='
',r+='
",r+="
",r+='
',r+='
",r+='
',r+=' ',r+=' ',r+="
",r+="
",r+='',z.innerHTML=r,document.getElementById("backup-save-schedule")?.addEventListener("click",I),document.getElementById("backup-run-now")?.addEventListener("click",c)}catch(p){z.innerHTML='
Failed to load schedule: '+escapeHtml(p.message)+"
"}}async function I(){var a=document.getElementById("backup-schedule-select")?.value,n=parseInt(document.getElementById("backup-retention-select")?.value)||5,e=document.getElementById("backup-encrypt-toggle")?.checked??!0,t=document.getElementById("backup-schedule-result");try{var s=await secureFetch("/api/v1/backups/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backups:{auto:{enabled:a!=="disabled",schedule:a==="disabled"?"daily":a,include:["all"],encrypt:e,verify:!0,retention:{keep:n},destinations:[{type:"local"}]}}})}),r=await s.json();t&&(t.innerHTML=r.success?"\u2705 Schedule saved":"\u26A0\uFE0F "+escapeHtml(r.error),t.style.display="block",t.style.background=r.success?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",t.style.border=r.success?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)",setTimeout(function(){t&&(t.style.display="none")},3e3))}catch(p){t&&(t.innerHTML="\u274C "+escapeHtml(p.message),t.style.display="block",t.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",t.style.border="1px solid var(--bad-fg)")}}async function c(){var a=document.getElementById("backup-run-now"),n=document.getElementById("backup-schedule-result");a&&(a.disabled=!0,a.innerHTML=' Running...');try{var e=await secureFetch("/api/v1/backups/execute",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({include:["all"],destinations:[{type:"local"}]})}),t=await e.json();if(n){if(t.success){var s=t.backup?.size?(t.backup.size/1024/1024).toFixed(2):"?";n.innerHTML="\u2705 Backup complete ("+s+" MB)",n.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",n.style.border="1px solid var(--ok-fg)"}else n.innerHTML="\u26A0\uFE0F "+escapeHtml(t.error),n.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",n.style.border="1px solid var(--bad-fg)";n.style.display="block"}l()}catch(r){n&&(n.innerHTML="\u274C "+escapeHtml(r.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)")}a&&(a.disabled=!1,a.innerHTML="\u25B6\uFE0F Run Backup Now")}async function l(){if(H){H.innerHTML='
Loading...
';try{var a=await fetch("/api/v1/backups/history?limit=50"),n=await a.json();if(!n.success||!n.history?.length){H.innerHTML='
\u{1F4CB} No backup history yet
';return}for(var e='
',t=0;t',e+='
',e+=' '+escapeHtml(s.name||"backup")+"",e+='
',e+=' '+escapeHtml(s.status)+"",s.status==="success"&&(e+=' '),e+="
",e+="
",e+='
',e+=" "+new Date(s.timestamp).toLocaleString()+" | "+r+" MB | "+(s.duration?(s.duration/1e3).toFixed(1)+"s":"--"),s.encrypted&&(e+=" | \u{1F512}"),e+="
",e+="
"}e+="",H.innerHTML=e,H.querySelectorAll(".backup-restore-btn").forEach(function(p){p.addEventListener("click",function(){window.__restoreServerBackup(p.dataset.backupId)})})}catch(p){H.innerHTML='
Failed: '+escapeHtml(p.message)+"
"}}}window.__restoreServerBackup=async function(a){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var n=await secureFetch("/api/v1/backups/restore/"+a,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),e=await n.json();e.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(e.error||"Unknown error"),"error")}catch(t){showNotification("Restore error: "+t.message,"error")}},document.querySelector('[data-panel="backup-automated"]')?.addEventListener("click",g),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",l)})(),(function(){injectModal("stats-modal",`
+
`);var P=document.getElementById("backup-modal"),$=document.getElementById("backup-restore-btn"),L=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),k=document.getElementById("backup-select-file"),f=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),E=document.getElementById("backup-preview"),R=document.getElementById("backup-preview-content"),O=document.getElementById("backup-do-restore-btn"),D=document.getElementById("backup-result"),N=document.getElementById("backup-schedule-container"),M=document.getElementById("backup-history-container"),H=null;$?.addEventListener("click",function(){P.classList.add("show"),D&&(D.style.display="none"),E&&(E.style.display="none"),x&&(x.style.display="none"),H=null}),wireModal(P,L),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML=' Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=B();var t=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),s=URL.createObjectURL(t),i=document.createElement("a");i.href=s,i.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(s);var h=Object.keys(a.browserState).length,d=a.themes?Object.keys(a.themes).length:0;D.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+h+" browser settings"+(d?" + "+d+" themes":""),D.style.display="block",D.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",D.style.border="1px solid var(--ok-fg)"}catch(A){D.innerHTML="\u274C Export failed: "+escapeHtml(A.message),D.style.display="block",D.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",D.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),k?.addEventListener("click",function(){f.click()}),f?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){x.textContent="\u{1F4C4} "+a.name,x.style.display="block",D.style.display="none";try{var t=await a.text(),s=JSON.parse(t);if(I(s)){H=s;var i='
Legacy format (v'+escapeHtml(s.version)+")
";i+='
',s.services?.length&&(i+='\u{1F4CB} '+s.services.length+" services"),s.customApps?.length&&(i+='\u{1F4E6} '+s.customApps.length+" custom apps"),s.theme&&(i+='\u{1F3A8} Theme: '+escapeHtml(s.theme)+""),s.userThemes&&(i+='\u{1F3A8} '+Object.keys(s.userThemes).length+" custom themes"),i+="
",R.innerHTML=i,E.style.display="block";return}var h=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)}),d=await h.json();if(d.success){H=s;var i='
Exported: '+new Date(s.exportedAt).toLocaleString()+" (v"+escapeHtml(s.version)+")
";i+='
Server Config
',i+='
';for(var A in d.preview.files){var F=d.preview.files[A],j=F.action==="create"?"\u{1F195}":"\u{1F4DD}";i+=''+j+" "+escapeHtml(F.description)+""}i+="
",d.preview.serviceCount&&(i+='
'+d.preview.serviceCount+" services
"),d.preview.themeCount&&(i+='
\u{1F3A8} '+d.preview.themeCount+" custom themes
"),d.preview.browserStateCount&&(i+='
Browser Preferences
',i+='
\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)
"),R.innerHTML=i,E.style.display="block"}else D.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),D.style.display="block",D.style.background="color-mix(in srgb, #f39c12 15%, transparent)",D.style.border="1px solid #f39c12",E.style.display="none"}catch(_){D.innerHTML="\u274C Could not read file: "+escapeHtml(_.message),D.style.display="block",D.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",D.style.border="1px solid var(--bad-fg)",E.style.display="none"}}}),O?.addEventListener("click",async function(){if(H&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){O.disabled=!0,O.innerHTML=' Restoring...';try{if(I(H)){C(H),D.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",D.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",D.style.border="1px solid var(--ok-fg)",D.style.display="block",setTimeout(function(){location.reload()},2e3),O.disabled=!1,O.innerHTML="\u26A1 Restore Everything";return}var e=document.getElementById("backup-reload-caddy")?.checked??!0,a=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:H,options:{reloadCaddy:e}})}),t=await a.json(),s=0;if(H.browserState&&(s=z(H.browserState)),t.success){var i="\u2705 "+t.message;s>0&&(i+='
'+s+" browser settings restored"),t.results.caddyReloaded&&(i+='
Caddy configuration reloaded'),D.innerHTML=i,D.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",D.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else D.innerHTML="\u26A0\uFE0F "+escapeHtml(t.message),s>0&&(D.innerHTML+='
'+s+" browser settings were restored"),t.results?.errors?.length>0&&(D.innerHTML+="
"+t.results.errors.map(function(h){return escapeHtml(h.file)+": "+escapeHtml(h.error)}).join(", ")+""),D.style.background="color-mix(in srgb, #f39c12 15%, transparent)",D.style.border="1px solid #f39c12";D.style.display="block"}catch(h){D.innerHTML="\u274C Restore failed: "+escapeHtml(h.message),D.style.display="block",D.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",D.style.border="1px solid var(--bad-fg)"}O.disabled=!1,O.innerHTML="\u26A1 Restore Everything"}});var S={type:"local"};async function T(){if(N)try{var e=await fetch("/api/v1/backups/config"),a=await e.json();if(!a.success)throw new Error(a.error||"Failed to load config");var t=a.config?.backups||{},s=Object.keys(t)[0],i=s?t[s]:null,h=i?.destinations&&i.destinations[0]||{type:"local"};S=JSON.parse(JSON.stringify(h));var d='
';d+='

\u23F0 Backup Schedule

',d+='
',d+='
',d+='
",d+='
',d+='
",d+="
",d+='
',d+='
",d+='
',d+=' ',d+=' ',d+="
",d+="
",d+='
',d+='

\u2601\uFE0F Backup Destination

',d+='
',d+='
",d+='
',d+='',d+="
",d+='',N.innerHTML=d,document.getElementById("backup-save-schedule")?.addEventListener("click",p),document.getElementById("backup-run-now")?.addEventListener("click",m);var A=document.getElementById("backup-dest-type");A?.addEventListener("change",function(){S={type:A.value},b(A.value)}),b(S.type)}catch(F){N.innerHTML='
Failed to load schedule: '+escapeHtml(F.message)+"
"}}async function b(e){var a=document.getElementById("backup-dest-form");if(a){if(e==="local"){a.innerHTML='
Backups are stored on the host filesystem. No additional configuration required.
';return}var t="";if(e==="dropbox"?(t+='',t+='',t+='',t+='',t+='
Generate a token at Dropbox App Console with files.content.write + files.content.read scopes.
'):e==="webdav"?(t+='',t+='',t+='
',t+='
',t+='
',t+='
',t+='
',t+="
",t+='',t+=''):e==="sftp"&&(t+='
',t+='
',t+='
',t+='
',t+='
',t+="
",t+='',t+='',t+='',t+='",t+='
',t+='
',t+='',t+='',t+=''),t+='
',t+=' ',t+=' ',t+=' ',t+="
",a.innerHTML=t,e==="sftp"){var s=document.getElementById("dest-sftp-authtype"),i=document.getElementById("dest-sftp-password-row"),h=document.getElementById("dest-sftp-key-row");s?.addEventListener("change",function(){s.value==="key"?(i.style.display="none",h.style.display=""):(i.style.display="",h.style.display="none")})}document.getElementById("dest-save-creds")?.addEventListener("click",function(){o(e)}),document.getElementById("dest-test-conn")?.addEventListener("click",function(){r(e)}),document.getElementById("dest-clear-creds")?.addEventListener("click",function(){l(e)}),await y(e)}}function u(e,a){var t=document.getElementById("backup-dest-result");t&&(t.innerHTML=e,t.style.display="block",t.style.background=a?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",t.style.border=a?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)")}async function y(e){try{var a=await fetch("/api/v1/backups/credentials/"+e),t=await a.json();if(!t.success||!t.credentials)return;var s=t.credentials;if(e==="dropbox"){var i=document.getElementById("dest-dropbox-token");i&&s.token&&(i.value=s.token)}else if(e==="webdav"){var h=document.getElementById("dest-webdav-url");h&&s.url&&(h.value=s.url);var d=document.getElementById("dest-webdav-username");d&&s.username&&(d.value=s.username);var A=document.getElementById("dest-webdav-password");A&&s.password&&(A.value=s.password)}else if(e==="sftp"){var F=document.getElementById("dest-sftp-host");F&&s.host&&(F.value=s.host);var j=document.getElementById("dest-sftp-port");j&&s.port&&(j.value=s.port);var _=document.getElementById("dest-sftp-username");_&&s.username&&(_.value=s.username);var U=document.getElementById("dest-sftp-password");U&&s.password&&(U.value=s.password);var W=document.getElementById("dest-sftp-privatekey");if(W&&s.privateKey&&(W.value=s.privateKey),s.privateKey){var Z=document.getElementById("dest-sftp-authtype");Z&&(Z.value="key",Z.dispatchEvent(new Event("change")))}}}catch{}}function c(e){if(e==="dropbox")return{token:document.getElementById("dest-dropbox-token")?.value};if(e==="webdav")return{url:document.getElementById("dest-webdav-url")?.value,username:document.getElementById("dest-webdav-username")?.value,password:document.getElementById("dest-webdav-password")?.value};if(e==="sftp"){var a=document.getElementById("dest-sftp-authtype")?.value,t={host:document.getElementById("dest-sftp-host")?.value,port:parseInt(document.getElementById("dest-sftp-port")?.value)||22,username:document.getElementById("dest-sftp-username")?.value};return a==="key"?t.privateKey=document.getElementById("dest-sftp-privatekey")?.value:t.password=document.getElementById("dest-sftp-password")?.value,t}return{}}async function o(e){try{var a=c(e),t=await secureFetch("/api/v1/backups/credentials/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),s=await t.json();u(s.success?"\u2705 Credentials saved":"\u26A0\uFE0F "+escapeHtml(s.error||"Failed"),s.success)}catch(i){u("\u274C "+escapeHtml(i.message),!1)}}async function l(e){if(confirm("Delete saved "+e+" credentials?"))try{var a=await secureFetch("/api/v1/backups/credentials/"+e,{method:"DELETE"}),t=await a.json();t.success?(u("\u2705 Credentials cleared",!0),b(e)):u("\u26A0\uFE0F "+escapeHtml(t.error||"Failed"),!1)}catch(s){u("\u274C "+escapeHtml(s.message),!1)}}function v(e){var a={type:e};return e==="local"||(e==="dropbox"?a.path=document.getElementById("dest-dropbox-path")?.value||"/dashcaddy-backups":e==="webdav"?a.path=document.getElementById("dest-webdav-path")?.value||"/dashcaddy-backups":e==="sftp"&&(a.path=document.getElementById("dest-sftp-path")?.value||"/dashcaddy-backups")),a}async function r(e){u(' Testing connection...',!0);try{var a=v(e),t=await secureFetch("/api/v1/backups/test-destination",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),s=await t.json();if(s.success){var i=s.elapsedMs?" ("+s.elapsedMs+"ms)":"";u("\u2705 Connection OK"+i+" \u2014 write/read/delete probe succeeded",!0)}else u("\u274C "+escapeHtml(s.error||"Connection failed"),!1)}catch(h){u("\u274C "+escapeHtml(h.message),!1)}}async function p(){var e=document.getElementById("backup-schedule-select")?.value,a=parseInt(document.getElementById("backup-retention-select")?.value)||5,t=document.getElementById("backup-encrypt-toggle")?.checked??!0,s=document.getElementById("backup-dest-type")?.value||"local",i=document.getElementById("backup-schedule-result");try{var h=await secureFetch("/api/v1/backups/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backups:{auto:{enabled:e!=="disabled",schedule:e==="disabled"?"daily":e,include:["all"],encrypt:t,verify:!0,retention:{keep:a},destinations:[v(s)]}}})}),d=await h.json();i&&(i.innerHTML=d.success?"\u2705 Schedule saved":"\u26A0\uFE0F "+escapeHtml(d.error),i.style.display="block",i.style.background=d.success?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",i.style.border=d.success?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)",setTimeout(function(){i&&(i.style.display="none")},3e3))}catch(A){i&&(i.innerHTML="\u274C "+escapeHtml(A.message),i.style.display="block",i.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",i.style.border="1px solid var(--bad-fg)")}}async function m(){var e=document.getElementById("backup-run-now"),a=document.getElementById("backup-schedule-result"),t=document.getElementById("backup-dest-type")?.value||"local";e&&(e.disabled=!0,e.innerHTML=' Running...');try{var s=await secureFetch("/api/v1/backups/execute",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({include:["all"],destinations:[v(t)]})}),i=await s.json();if(a){if(i.success){var h=i.backup?.size?(i.backup.size/1024/1024).toFixed(2):"?";a.innerHTML="\u2705 Backup complete ("+h+" MB)",a.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",a.style.border="1px solid var(--ok-fg)"}else a.innerHTML="\u26A0\uFE0F "+escapeHtml(i.error),a.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",a.style.border="1px solid var(--bad-fg)";a.style.display="block"}n()}catch(d){a&&(a.innerHTML="\u274C "+escapeHtml(d.message),a.style.display="block",a.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",a.style.border="1px solid var(--bad-fg)")}e&&(e.disabled=!1,e.innerHTML="\u25B6\uFE0F Run Backup Now")}async function n(){if(M){M.innerHTML='
Loading...
';try{var e=await fetch("/api/v1/backups/history?limit=50"),a=await e.json();if(!a.success||!a.history?.length){M.innerHTML='
\u{1F4CB} No backup history yet
';return}for(var t='
',s=0;s',t+='
',t+=' '+escapeHtml(i.name||"backup")+"",t+='
',t+=' '+escapeHtml(i.status)+"",i.status==="success"&&(t+=' '),t+="
",t+="
",t+='
',t+=" "+new Date(i.timestamp).toLocaleString()+" | "+h+" MB | "+(i.duration?(i.duration/1e3).toFixed(1)+"s":"--"),i.encrypted&&(t+=" | \u{1F512}"),t+="
",t+="
"}t+="",M.innerHTML=t,M.querySelectorAll(".backup-restore-btn").forEach(function(d){d.addEventListener("click",function(){window.__restoreServerBackup(d.dataset.backupId)})})}catch(d){M.innerHTML='
Failed: '+escapeHtml(d.message)+"
"}}}window.__restoreServerBackup=async function(e){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var a=await secureFetch("/api/v1/backups/restore/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),t=await a.json();t.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(t.error||"Unknown error"),"error")}catch(s){showNotification("Restore error: "+s.message,"error")}},document.querySelector('[data-panel="backup-automated"]')?.addEventListener("click",T),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",n)})(),(function(){injectModal("stats-modal",`

\u{1F4CA} Resource Monitor

+
@@ -1009,6 +1010,27 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
+ +
+
+ + +
+ + + + + +
+
+
+
+ \u{1F4CA} + Choose a container and time range to view history. +
+
+
+
@@ -1034,80 +1056,94 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
- `);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='
No running containers found
';return}let c='
';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+=` +
`);const w=document.getElementById("stats-modal"),B=document.getElementById("container-stats-btn"),z=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),C=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),$=document.getElementById("stats-aggregated-container"),L=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let k=null,f=null;function x(r){if(r===0||!r)return"0 B";const p=1024,m=["B","KB","MB","GB"],n=Math.floor(Math.log(r)/Math.log(p));return parseFloat((r/Math.pow(p,n)).toFixed(1))+" "+m[n]}function E(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function R(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function O(){try{let r=null,p=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,p=!0,f=e.stats)}catch{}if(!p){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const a of e.stats)r[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};f=r}}if(!r||Object.keys(r).length===0){P.innerHTML='
No running containers found
';return}let m='
';for(const[n,e]of Object.entries(r)){const a=e.current||e,t=a.cpu?.percent||0,s=a.memory?.percent||0,i=E(t),h=R(s),d=a.memory?.usage||a.memory?.used||0,A=a.memory?.limit||0,F=a.network?.rxBytes||a.network?.rx||0,j=a.network?.txBytes||a.network?.tx||0,_=e.aggregated;m+=`
- ${a.name||l} - ${v?`avg ${v.cpu?.avg?.toFixed(0)||0}% cpu`:""} - ${a.status||"running"} + ${e.name||n} + ${_?`avg ${_.cpu?.avg?.toFixed(0)||0}% cpu`:""} + ${e.status||"running"}
CPU
-
+
- ${e.toFixed(1)}% + ${t.toFixed(1)}%
Memory
-
+
- ${t.toFixed(1)}% + ${s.toFixed(1)}%
-
${y(p)} / ${y(b)}
+
${x(d)} / ${x(A)}
Network
- \u2193 ${y(o)} + \u2193 ${x(F)} / - \u2191 ${y(i)} + \u2191 ${x(j)}
-
`}c+="
",D.innerHTML=c,m.textContent="Updated: "+new Date().toLocaleTimeString()}catch(g){D.innerHTML=`
\u274C Failed to load stats: ${escapeHtml(g.message)}
`}}async function N(){if(!B)return;const g=u;if(!g||Object.keys(g).length===0){B.innerHTML='
\u{1F4C8}No monitoring data available. Open the Live Stats tab first.
';return}let I='
';for(const[c,l]of Object.entries(g)){const a=l.aggregated;a&&(I+=`
-
${l.name||c}
+
`}m+="
",P.innerHTML=m,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){P.innerHTML=`
\u274C Failed to load stats: ${escapeHtml(r.message)}
`}}async function D(){if(!$)return;const r=f;if(!r||Object.keys(r).length===0){$.innerHTML='
\u{1F4C8}No monitoring data available. Open the Live Stats tab first.
';return}let p='
';for(const[m,n]of Object.entries(r)){const e=n.aggregated;e&&(p+=`
+
${n.name||m}
-
${a.cpu?.avg?.toFixed(1)||0}%Avg CPU
-
${a.cpu?.max?.toFixed(1)||0}%Max CPU
-
${a.memory?.avg?.toFixed(1)||0}%Avg Mem
-
${a.memory?.max?.toFixed(1)||0}%Max Mem
+
${e.cpu?.avg?.toFixed(1)||0}%Avg CPU
+
${e.cpu?.max?.toFixed(1)||0}%Max CPU
+
${e.memory?.avg?.toFixed(1)||0}%Avg Mem
+
${e.memory?.max?.toFixed(1)||0}%Max Mem
- ${a.dataPoints?`
${a.dataPoints} data points over ${a.timeRange||24}h
`:""} -
`)}I+="
",B.innerHTML=I}async function z(){if(!C)return;C.innerHTML='
Loading alerts...
';const g=u;if(!g||Object.keys(g).length===0){C.innerHTML='
\u{1F514}No containers found. Open the Live Stats tab first.
';return}let I='
';for(const[c,l]of Object.entries(g)){const a=l.alertConfig||{};I+=`
+ ${e.dataPoints?`
${e.dataPoints} data points over ${e.timeRange||24}h
`:""} +
`)}p+="
",$.innerHTML=p}async function N(){if(!L)return;L.innerHTML='
Loading alerts...
';const r=f;if(!r||Object.keys(r).length===0){L.innerHTML='
\u{1F514}No containers found. Open the Live Stats tab first.
';return}let p='
';for(const[m,n]of Object.entries(r)){const e=n.alertConfig||{};p+=`
- ${l.name||c} + ${n.name||m}
- +
- +
- +
- +
-
`}I+="
",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",`
+
`}p+="",L.innerHTML=p,L.querySelectorAll(".alert-save-btn").forEach(m=>{m.addEventListener("click",async()=>{const n=m.dataset.container,e=L.querySelector(`.alert-enabled[data-container="${n}"]`)?.checked||!1,a=parseInt(L.querySelector(`.alert-cpu[data-container="${n}"]`)?.value)||80,t=parseInt(L.querySelector(`.alert-mem[data-container="${n}"]`)?.value)||85,s=parseInt(L.querySelector(`.alert-cooldown[data-container="${n}"]`)?.value)||15,i=L.querySelector(`.alert-autorestart[data-container="${n}"]`)?.checked||!1;try{const d=await(await secureFetch(`/api/v1/monitoring/alerts/${n}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:a,memoryThreshold:t,cooldownMinutes:s,autoRestart:i})})).json();m.textContent=d.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{m.textContent="Save"},2e3)}catch{m.textContent="\u274C Error",setTimeout(()=>{m.textContent="Save"},2e3)}})})}function M(){k&&clearInterval(k),C?.checked&&(k=setInterval(O,DC.POLL.STATS))}function H(){k&&(clearInterval(k),k=null)}B?.addEventListener("click",()=>{w.classList.add("show"),O(),M()}),z?.addEventListener("click",()=>{w.classList.remove("show"),H()}),w?.addEventListener("click",r=>{r.target===w&&(w.classList.remove("show"),H())}),I?.addEventListener("click",O),C?.addEventListener("change",()=>{C.checked?M():H()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",D),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",N);const S=document.getElementById("stats-history-container"),T=document.getElementById("stats-history-container-area"),b=document.querySelectorAll(".stats-range-btn");let u="1h";function y(r){switch(r){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function c(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function o(r,p,m,n,e){if(!r||r.length===0)return`
No data for ${escapeHtml(n)}
`;const a=r.map(p).filter(W=>W!=null);if(a.length===0)return`
No data for ${escapeHtml(n)}
`;const t=Math.max(...a,1),s=Math.min(...a,0),i=t-s||1,h=600,d=80,A=4,F=(h-A*2)/Math.max(a.length-1,1),j=a.map((W,Z)=>{const G=A+Z*F,Q=d-A-(W-s)/i*(d-A*2);return`${G.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=a[a.length-1],U=a.reduce((W,Z)=>W+Z,0)/a.length;return` +
+
+ ${escapeHtml(n)} + last ${_.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e} +
+ + + +
+ `}function l(){if(!S)return;const r=f||{},p=S.value,m=Object.entries(r);if(m.length===0){S.innerHTML='';return}S.innerHTML=m.map(([n,e])=>``).join(""),p&&r[p]&&(S.value=p)}async function v(){if(!T||!S)return;const r=S.value;if(!r){T.innerHTML='
\u{1F4CA}No container selected.
';return}const p=Date.now(),m=p-y(u);T.innerHTML='
Loading history...
';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(r)}?startTime=${m}&endTime=${p}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],t=e.tier||"raw";if(a.length===0){T.innerHTML=`
\u{1F4CA}No data for the last ${u}. Tier: ${c(t)}.
`;return}const s=t==="raw",i=s?j=>j.cpu?.percent:j=>j.cpu?.avg,h=s?j=>j.memory?.percent:j=>j.memory?.avgPercent,d=s?j=>j.network?.rxMB||0:j=>j.network?.rxMB||0,A=s?j=>j.network?.txMB||0:j=>j.network?.txMB||0;let F=` +
+ ${a.length} samples \xB7 ${escapeHtml(c(t))} \xB7 ${new Date(m).toLocaleString()} \u2192 ${new Date(p).toLocaleString()} +
+ `;F+=o(a,i,"#2ecc71","CPU","%"),F+=o(a,h,"#3498db","Memory","%"),F+=o(a,d,"#9b59b6","Network RX"," MB"),F+=o(a,A,"#e67e22","Network TX"," MB"),T.innerHTML=F}catch(n){T.innerHTML=`
\u26A0\uFE0FFailed to load history: ${escapeHtml(n.message)}
`}}b.forEach(r=>{r.addEventListener("click",()=>{b.forEach(p=>p.classList.remove("active")),r.classList.add("active"),u=r.dataset.range,v()})}),S?.addEventListener("change",v),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{l(),v()})})(),(function(){injectModal("health-modal",`

\u{1F3E5} Health Check Dashboard

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

Open Incidents ('+t.length+")

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

Open Incidents ('+l.length+")

";for(const r of l)o+=`
${escapeHtml(r.serviceId)} - ${N(r.severity)} + ${D(r.severity)}
${escapeHtml(r.message)}
Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)
-
`;e+="
"}else e+='
All services operational \u2014 no open incidents
';const s=n.success&&n.history?n.history:[];if(s.length>0){e+='

Incident History

',e+='',e+='';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+='',e+=``,e+=``,e+=``,e+=``,e+=``,e+=``,e+=""}e+="
ServiceTypeSeverityStatusDurationWhen
${escapeHtml(r.serviceId)}${escapeHtml(r.type)}${N(r.severity)}${r.status}${b}${timeAgo(r.createdAt)}
"}D.innerHTML=e||'
\u{1F6A8}No incidents recorded yet.
'}catch(c){D.innerHTML=`
Failed: ${escapeHtml(c.message)}
`}}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='
\u2699\uFE0FNo health checks configured yet. Click "Add Health Check" below.
';return}let n='';n+='';for(const e of a){const t=e.status==="up";n+='',n+=``,n+=``,n+=``,n+='"}n+="
ServiceStatusSLA TargetActions
${escapeHtml(e.name||e.serviceId)}${t?"Up":"Down"}${e.sla?.target?e.sla.target+"%":"-"}',n+=``,n+=``,n+="
",B.innerHTML=n}catch(c){B.innerHTML=`
Failed: ${escapeHtml(c.message)}
`}}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)||5e3};try{x.textContent="Saving...",x.disabled=!0;const t=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(c)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();if(!t.success)throw new Error(t.error||"Save failed");I(),L(),z()}catch(e){showNotification("Error: "+e.message,"error")}finally{x.textContent="Save",x.disabled=!1}}),document.addEventListener("health-edit",async c=>{const l=c.detail;g(l,"","",1e4,"200",99.9,5e3)}),document.addEventListener("health-delete",async c=>{const l=c.detail;if(confirm(`Delete health check for "${l}"?`))try{const n=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(l)}/configure`,{method:"DELETE"})).json();if(!n.success)throw new Error(n.error);L(),z()}catch(a){showNotification("Error: "+a.message,"error")}}),E?.addEventListener("click",()=>{f?.classList.add("show"),z()}),wireModal(f,M),k?.addEventListener("click",z),document.querySelector('[data-panel="health-incidents"]')?.addEventListener("click",H),document.querySelector('[data-panel="health-config"]')?.addEventListener("click",L)})(),(function(){injectModal("updates-modal",`
+
`;o+="
"}else o+='
All services operational \u2014 no open incidents
';const v=c.success&&c.history?c.history:[];if(v.length>0){o+='

Incident History

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

\u2B06\uFE0F Update Management

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

\u{1F433} Docker Resources

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

\u{1F4E6} Import Docker Compose

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

Terminal

@@ -1391,11 +1427,11 @@ Enter version to rollback to:`);if(!i)return;if(!o.includes(i)){showNotification
- `);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='
xterm.js not loaded
';return}k=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(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",`
+
`);const w=document.getElementById("exec-modal"),B=document.getElementById("exec-terminal"),z=document.getElementById("exec-close");let I=null,C=null,P=null;function $(){if(C){try{C.close()}catch{}C=null}if(I){try{I.dispose()}catch{}I=null}P=null,B.innerHTML=""}function L(g,k){if($(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${k||g}`,w?.classList.add("show"),typeof Terminal>"u"){B.innerHTML='
xterm.js not loaded
';return}I=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,I.loadAddon(P)),I.open(B),P&&setTimeout(()=>P.fit(),50);const f=location.protocol==="https:"?"wss:":"ws:";C=new WebSocket(`${f}//${location.host}/ws/exec/${encodeURIComponent(g)}`),C.binaryType="arraybuffer",C.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),P){const E=P.proposeDimensions();E&&C.send(JSON.stringify({type:"resize",cols:E.cols,rows:E.rows}))}},C.onmessage=E=>{if(typeof E.data=="string"){try{const R=JSON.parse(E.data);if(R.type==="connected"){I.writeln(`\x1B[32mConnected (${R.shell})\x1B[0m\r +`);return}if(R.type==="error"){I.writeln(`\x1B[31mError: ${R.message}\x1B[0m`);return}if(R.type==="exit"){I.writeln(`\r +\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(E.data)}else I.write(new Uint8Array(E.data))},C.onclose=()=>{I&&I.writeln(`\r +\x1B[33mDisconnected.\x1B[0m`)},C.onerror=()=>{I&&I.writeln(`\r +\x1B[31mConnection error.\x1B[0m`)},I.onData(E=>{C&&C.readyState===WebSocket.OPEN&&C.send(E)}),I.onResize(({cols:E,rows:R})=>{C&&C.readyState===WebSocket.OPEN&&C.send(JSON.stringify({type:"resize",cols:E,rows:R}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),w._resizeHandler=x}z?.addEventListener("click",()=>{$(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show")}),w?.addEventListener("click",g=>{g.target===w&&($(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show"))}),window.openExecModal=L})(),(function(){injectModal("audit-modal",`

\u{1F4DC} Audit Log

- `);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='
Loading...
');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='
\u{1F4DC}No audit log entries yet. Actions will be logged automatically.
',C.style.display="none";return}let H="";y||(H='',H+='');for(const L of z){const g=L.outcome==="success";H+='',H+=``,H+=``,H+=``,H+=``,H+=``,H+="",L.details&&Object.keys(L.details).length>0&&(H+=``)}if(!y)H+="
WhenIPActionResourceResult
${timeAgo(L.timestamp)}${escapeHtml(L.ip||"-")}${escapeHtml(L.action||"-")}${escapeHtml(L.resource||"-")}${g?"\u2713":"\u2717"}
",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=`
Failed: ${escapeHtml(x.message)}
`}}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",`

Weather Settings

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

Weather Settings

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

Clock Settings

- ${e} + ${o}
-
`);const t=document.getElementById("clock-settings-modal"),s=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function b(){const o=safeGet("clock-style")||"default",i=t.querySelector(`input[value="${o}"]`);i&&(i.checked=!0),s.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=s.checked?"1":"0.4"}s?.addEventListener("change",()=>{p.style.opacity=s.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{b(),t.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const o=parseInt(r.value,10)/100,i=new Audio("/assets/sounds/church-bell.mp3");i.volume=o,i.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const o=t.querySelector('input[name="clock-style-radio"]:checked'),i=o?o.value:"default";safeSet("clock-style",i),safeSet("clock-chimes",String(s.checked)),safeSet("clock-chime-volume",r.value),D=i,m="",A(),l(),a(),t.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{t.classList.remove("show")}),wireModal(t),t?.querySelectorAll('input[name="clock-style-radio"]').forEach(o=>{o.addEventListener("change",()=>{D=o.value,m="",A(),l()})})})(),(function(){async function f(){try{const B=await(await fetch("/api/v1/health-checks/status")).json();if(!B.success||!B.status)return;for(const[C,m]of Object.entries(B.status)){const h=document.getElementById("uptime-"+C),u=document.getElementById("uptime-bar-"+C);if(!h)continue;const y=m.uptime?.["24h"];if(y!=null){const x=y.toFixed(1);h.textContent=`${x}% uptime`,h.className="uptime-chip",y>=99.9?h.classList.add("excellent"):y>=99?h.classList.add("good"):y>=95?h.classList.add("degraded"):h.classList.add("poor"),u&&(u.style.width=x+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}async function M(){try{const B=await(await fetch("/api/v1/updates/available")).json();if(!B.success||(document.querySelectorAll(".update-available-badge").forEach(C=>C.classList.remove("visible")),!B.updates?.length))return;for(const C of B.updates){const m=window.APPS||[];for(const h of m)if(h.containerId===C.containerId||h.id===C.containerName||h.name===C.containerName){if(E.has(h.id))break;const u=document.getElementById("update-badge-"+h.id);u&&(u.classList.add("visible"),u.title=`Image digest changed. Click to dismiss if already up to date. -${C.imageName||""}`,u.style.cursor="pointer",u.onclick=y=>{y.stopPropagation(),u.classList.remove("visible"),E.add(h.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function k(){setTimeout(()=>{f(),M()},5e3),setInterval(()=>{f(),M()},6e4)}const S=window.refreshAll;S&&(window.refreshAll=async function(){try{await S(),setTimeout(f,1e3)}catch(D){console.warn("[Card Badges] Error in refreshAll hook:",D.message)}}),k()})(),(function(){var f=null,E=null,M={},k={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},S=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],D=document.getElementById("theme");if(!D)return;var B=document.getElementById("theme-label");function C(d){if(k[d])return k[d];var w=safeGetJSON(window.USER_THEMES_KEY,{});return w[d]&&w[d].name||d}function m(){B&&(B.textContent=C(window.getActiveTheme()))}D.addEventListener("click",function(){var d=window.THEMES.slice(),w=window.getActiveTheme(),T=d.indexOf(w),$=d[(T+1)%d.length];window.applyTheme($),m()}),m();function h(){var d={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},w={};S.forEach(function($){w[$[2]]||(w[$[2]]=[]),w[$[2]].push($)});var T="";return Object.keys(d).forEach(function($){$==="advanced"?(T+='
Show advanced colors ▼
',T+='`).join("")}async function M(){try{const c=await(await fetch("/api/v1/license/status")).json();c.success&&(N(c.license),T(c.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function H(){const y=B.value.trim();if(!y){O("Please enter a license code.");return}R(),z.disabled=!0,z.textContent="Activating...";try{const o=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();o.success?(D(o.message),B.value="",N(o.license),showNotification("License activated! Premium features unlocked.","success",5e3),T(o.license)):O(o.error||"Activation failed")}catch(c){O("Network error: "+c.message)}finally{z.disabled=!1,z.textContent="Activate"}}async function S(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const c=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();c.success?(D(c.message),await M(),showNotification("License deactivated.","info",3e3),T({active:!1})):O(c.error||"Deactivation failed")}catch(y){O("Network error: "+y.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function T(y){const c=document.getElementById("license-status-topbar"),o=document.getElementById("license-topbar-icon"),l=document.getElementById("license-topbar-text"),v=document.getElementById("license-topbar-time");if(c)if(c.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(o.textContent="\u2605",l.textContent="PREMIUM",y.lifetime)v.textContent="\xB7 LIFETIME";else{const r=y.daysRemaining;v.textContent=r!=null?"\xB7 "+r+"d remaining":""}else o.textContent="\u2606",l.textContent=y.expired?"EXPIRED":"FREE TIER",v.textContent=""}function b(){R(),M(),w.classList.add("show")}B.addEventListener("input",function(){let y=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(y.length>this._prevLength&&(y=y.replace(/-/g,""),y.length>2&&!y.startsWith("DC")&&(y="DC"+y),y.startsWith("DC")&&y.length>2)){const c=["DC"],o=y.substring(2);for(let l=0;l{y.key==="Enter"&&H()}),wireModal(w,document.getElementById("license-cancel"));const u=document.getElementById("license-status-topbar");u&&u.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=b,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},M().then(y=>{E&&T(E)})})(); diff --git a/status/js/backup-restore.js b/status/js/backup-restore.js index f462aec..0fd1866 100644 --- a/status/js/backup-restore.js +++ b/status/js/backup-restore.js @@ -384,6 +384,9 @@ }); // === Automated Backups Tab === + // Holds the destination currently being edited in the form + var currentDestination = { type: 'local' }; + async function loadBackupSchedule() { if (!scheduleContainer) return; try { @@ -394,6 +397,10 @@ var autoKey = Object.keys(cfg)[0]; var auto = autoKey ? cfg[autoKey] : null; + // Pull existing destination (first one) — fall back to local + var existingDest = (auto?.destinations && auto.destinations[0]) || { type: 'local' }; + currentDestination = JSON.parse(JSON.stringify(existingDest)); + var html = '
'; html += '

⏰ Backup Schedule

'; html += '
'; @@ -423,20 +430,238 @@ html += ' '; html += '
'; html += '
'; + + // === Destination Section === + html += '
'; + html += '

☁️ Backup Destination

'; + html += '
'; + html += '
'; + html += '
'; + html += ''; + html += '
'; + html += ''; scheduleContainer.innerHTML = html; document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule); document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow); + + var destTypeSel = document.getElementById('backup-dest-type'); + destTypeSel?.addEventListener('change', function() { + currentDestination = { type: destTypeSel.value }; + renderDestinationForm(destTypeSel.value); + }); + renderDestinationForm(currentDestination.type); } catch (e) { scheduleContainer.innerHTML = '
Failed to load schedule: ' + escapeHtml(e.message) + '
'; } } + // Render the provider-specific form fields and load saved credentials (masked) + async function renderDestinationForm(type) { + var formEl = document.getElementById('backup-dest-form'); + if (!formEl) return; + + if (type === 'local') { + formEl.innerHTML = '
Backups are stored on the host filesystem. No additional configuration required.
'; + return; + } + + var html = ''; + + if (type === 'dropbox') { + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Generate a token at Dropbox App Console with files.content.write + files.content.read scopes.
'; + } else if (type === 'webdav') { + html += ''; + html += ''; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ''; + html += ''; + } else if (type === 'sftp') { + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + } + + html += '
'; + html += ' '; + html += ' '; + html += ' '; + html += '
'; + + formEl.innerHTML = html; + + // SFTP auth type toggle + if (type === 'sftp') { + var authSel = document.getElementById('dest-sftp-authtype'); + var pwRow = document.getElementById('dest-sftp-password-row'); + var keyRow = document.getElementById('dest-sftp-key-row'); + authSel?.addEventListener('change', function() { + if (authSel.value === 'key') { pwRow.style.display = 'none'; keyRow.style.display = ''; } + else { pwRow.style.display = ''; keyRow.style.display = 'none'; } + }); + } + + document.getElementById('dest-save-creds')?.addEventListener('click', function() { saveCredentials(type); }); + document.getElementById('dest-test-conn')?.addEventListener('click', function() { testDestination(type); }); + document.getElementById('dest-clear-creds')?.addEventListener('click', function() { clearCredentials(type); }); + + // Pull existing (masked) credentials + await loadCredentials(type); + } + + function destResult(msg, ok) { + var el = document.getElementById('backup-dest-result'); + if (!el) return; + el.innerHTML = msg; + el.style.display = 'block'; + el.style.background = ok ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; + el.style.border = ok ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)'; + } + + async function loadCredentials(provider) { + try { + var res = await fetch('/api/v1/backups/credentials/' + provider); + var data = await res.json(); + if (!data.success || !data.credentials) return; + var c = data.credentials; + if (provider === 'dropbox') { + var t = document.getElementById('dest-dropbox-token'); if (t && c.token) t.value = c.token; + } else if (provider === 'webdav') { + var u = document.getElementById('dest-webdav-url'); if (u && c.url) u.value = c.url; + var n = document.getElementById('dest-webdav-username'); if (n && c.username) n.value = c.username; + var p = document.getElementById('dest-webdav-password'); if (p && c.password) p.value = c.password; + } else if (provider === 'sftp') { + var h = document.getElementById('dest-sftp-host'); if (h && c.host) h.value = c.host; + var po = document.getElementById('dest-sftp-port'); if (po && c.port) po.value = c.port; + var un = document.getElementById('dest-sftp-username'); if (un && c.username) un.value = c.username; + var pw = document.getElementById('dest-sftp-password'); if (pw && c.password) pw.value = c.password; + var pk = document.getElementById('dest-sftp-privatekey'); if (pk && c.privateKey) pk.value = c.privateKey; + if (c.privateKey) { + var sel = document.getElementById('dest-sftp-authtype'); + if (sel) { sel.value = 'key'; sel.dispatchEvent(new Event('change')); } + } + } + } catch (e) { /* no creds yet — silent */ } + } + + function collectCredentials(provider) { + if (provider === 'dropbox') { + return { token: document.getElementById('dest-dropbox-token')?.value }; + } + if (provider === 'webdav') { + return { + url: document.getElementById('dest-webdav-url')?.value, + username: document.getElementById('dest-webdav-username')?.value, + password: document.getElementById('dest-webdav-password')?.value + }; + } + if (provider === 'sftp') { + var auth = document.getElementById('dest-sftp-authtype')?.value; + var creds = { + host: document.getElementById('dest-sftp-host')?.value, + port: parseInt(document.getElementById('dest-sftp-port')?.value) || 22, + username: document.getElementById('dest-sftp-username')?.value + }; + if (auth === 'key') creds.privateKey = document.getElementById('dest-sftp-privatekey')?.value; + else creds.password = document.getElementById('dest-sftp-password')?.value; + return creds; + } + return {}; + } + + async function saveCredentials(provider) { + try { + var creds = collectCredentials(provider); + var res = await secureFetch('/api/v1/backups/credentials/' + provider, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(creds) + }); + var data = await res.json(); + destResult(data.success ? '✅ Credentials saved' : '⚠️ ' + escapeHtml(data.error || 'Failed'), data.success); + } catch (e) { + destResult('❌ ' + escapeHtml(e.message), false); + } + } + + async function clearCredentials(provider) { + if (!confirm('Delete saved ' + provider + ' credentials?')) return; + try { + var res = await secureFetch('/api/v1/backups/credentials/' + provider, { method: 'DELETE' }); + var data = await res.json(); + if (data.success) { + destResult('✅ Credentials cleared', true); + renderDestinationForm(provider); + } else { + destResult('⚠️ ' + escapeHtml(data.error || 'Failed'), false); + } + } catch (e) { destResult('❌ ' + escapeHtml(e.message), false); } + } + + function buildDestination(type) { + var dest = { type: type }; + if (type === 'local') return dest; + if (type === 'dropbox') dest.path = document.getElementById('dest-dropbox-path')?.value || '/dashcaddy-backups'; + else if (type === 'webdav') dest.path = document.getElementById('dest-webdav-path')?.value || '/dashcaddy-backups'; + else if (type === 'sftp') dest.path = document.getElementById('dest-sftp-path')?.value || '/dashcaddy-backups'; + return dest; + } + + async function testDestination(type) { + destResult(' Testing connection...', true); + try { + var dest = buildDestination(type); + var res = await secureFetch('/api/v1/backups/test-destination', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dest) + }); + var data = await res.json(); + if (data.success) { + var ms = data.elapsedMs ? ' (' + data.elapsedMs + 'ms)' : ''; + destResult('✅ Connection OK' + ms + ' — write/read/delete probe succeeded', true); + } else { + destResult('❌ ' + escapeHtml(data.error || 'Connection failed'), false); + } + } catch (e) { destResult('❌ ' + escapeHtml(e.message), false); } + } + async function saveSchedule() { var schedule = document.getElementById('backup-schedule-select')?.value; var retention = parseInt(document.getElementById('backup-retention-select')?.value) || 5; var encrypt = document.getElementById('backup-encrypt-toggle')?.checked ?? true; + var destType = document.getElementById('backup-dest-type')?.value || 'local'; var resultEl = document.getElementById('backup-schedule-result'); try { var res = await secureFetch('/api/v1/backups/config', { @@ -451,7 +676,7 @@ encrypt: encrypt, verify: true, retention: { keep: retention }, - destinations: [{ type: 'local' }] + destinations: [buildDestination(destType)] } } }) @@ -477,12 +702,13 @@ async function runBackupNow() { var btn = document.getElementById('backup-run-now'); var resultEl = document.getElementById('backup-schedule-result'); + var destType = document.getElementById('backup-dest-type')?.value || 'local'; if (btn) { btn.disabled = true; btn.innerHTML = ' Running...'; } try { var res = await secureFetch('/api/v1/backups/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ include: ['all'], destinations: [{ type: 'local' }] }) + body: JSON.stringify({ include: ['all'], destinations: [buildDestination(destType)] }) }); var data = await res.json(); if (resultEl) { diff --git a/status/js/resource-monitor.js b/status/js/resource-monitor.js index 70b6a0a..360894a 100644 --- a/status/js/resource-monitor.js +++ b/status/js/resource-monitor.js @@ -12,6 +12,7 @@
+
@@ -34,6 +35,27 @@ + +
+
+ + +
+ + + + + +
+
+
+
+ 📊 + Choose a container and time range to view history. +
+
+
+
@@ -325,4 +347,141 @@ // Lazy-load tabs document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener('click', loadAggregated); document.querySelector('[data-panel="stats-alerts"]')?.addEventListener('click', loadAlerts); + + // === History Tab === + const historyContainerSelect = document.getElementById('stats-history-container'); + const historyArea = document.getElementById('stats-history-container-area'); + const rangeButtons = document.querySelectorAll('.stats-range-btn'); + + let currentRange = '1h'; + + function rangeToMs(range) { + switch (range) { + case '1h': return 60 * 60 * 1000; + case '24h': return 24 * 60 * 60 * 1000; + case '7d': return 7 * 24 * 60 * 60 * 1000; + case '30d': return 30 * 24 * 60 * 60 * 1000; + case '1y': return 365 * 24 * 60 * 60 * 1000; + default: return 60 * 60 * 1000; + } + } + + function tierLabel(tier) { + if (tier === 'raw') return 'live (10s samples)'; + if (tier === 'hourly') return 'hourly average'; + if (tier === 'daily') return 'daily average'; + return tier; + } + + // Simple SVG sparkline — no external lib + function renderSparkline(samples, accessor, color, label, unit) { + if (!samples || samples.length === 0) { + return `
No data for ${escapeHtml(label)}
`; + } + const values = samples.map(accessor).filter(v => v != null); + if (values.length === 0) { + return `
No data for ${escapeHtml(label)}
`; + } + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const w = 600, h = 80, pad = 4; + const stepX = (w - pad * 2) / Math.max(values.length - 1, 1); + const points = values.map((v, i) => { + const x = pad + i * stepX; + const y = h - pad - ((v - min) / range) * (h - pad * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + const last = values[values.length - 1]; + const avg = values.reduce((a, b) => a + b, 0) / values.length; + return ` +
+
+ ${escapeHtml(label)} + last ${last.toFixed(1)}${unit} · avg ${avg.toFixed(1)}${unit} · max ${max.toFixed(1)}${unit} +
+ + + +
+ `; + } + + function populateHistoryContainerSelect() { + if (!historyContainerSelect) return; + const data = cachedMonitoringData || {}; + const previous = historyContainerSelect.value; + const entries = Object.entries(data); + if (entries.length === 0) { + historyContainerSelect.innerHTML = ''; + return; + } + historyContainerSelect.innerHTML = entries + .map(([id, info]) => ``) + .join(''); + if (previous && data[previous]) historyContainerSelect.value = previous; + } + + async function loadHistory() { + if (!historyArea || !historyContainerSelect) return; + const containerId = historyContainerSelect.value; + if (!containerId) { + historyArea.innerHTML = '
📊No container selected.
'; + return; + } + const endTime = Date.now(); + const startTime = endTime - rangeToMs(currentRange); + historyArea.innerHTML = '
Loading history...
'; + + try { + const res = await fetch(`/api/v1/monitoring/history/${encodeURIComponent(containerId)}?startTime=${startTime}&endTime=${endTime}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to load history'); + + const samples = data.samples || []; + const tier = data.tier || 'raw'; + + if (samples.length === 0) { + historyArea.innerHTML = `
📊No data for the last ${currentRange}. Tier: ${tierLabel(tier)}.
`; + return; + } + + // Different shape between raw vs rolled-up samples + const isRaw = tier === 'raw'; + const cpuAccessor = isRaw ? (s) => s.cpu?.percent : (s) => s.cpu?.avg; + const memAccessor = isRaw ? (s) => s.memory?.percent : (s) => s.memory?.avgPercent; + const netRxAccessor = isRaw ? (s) => (s.network?.rxMB || 0) : (s) => (s.network?.rxMB || 0); + const netTxAccessor = isRaw ? (s) => (s.network?.txMB || 0) : (s) => (s.network?.txMB || 0); + + let html = ` +
+ ${samples.length} samples · ${escapeHtml(tierLabel(tier))} · ${new Date(startTime).toLocaleString()} → ${new Date(endTime).toLocaleString()} +
+ `; + html += renderSparkline(samples, cpuAccessor, '#2ecc71', 'CPU', '%'); + html += renderSparkline(samples, memAccessor, '#3498db', 'Memory', '%'); + html += renderSparkline(samples, netRxAccessor, '#9b59b6', 'Network RX', ' MB'); + html += renderSparkline(samples, netTxAccessor, '#e67e22', 'Network TX', ' MB'); + + historyArea.innerHTML = html; + } catch (e) { + historyArea.innerHTML = `
⚠️Failed to load history: ${escapeHtml(e.message)}
`; + } + } + + rangeButtons.forEach(btn => { + btn.addEventListener('click', () => { + rangeButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentRange = btn.dataset.range; + loadHistory(); + }); + }); + + historyContainerSelect?.addEventListener('change', loadHistory); + + document.querySelector('[data-panel="stats-history"]')?.addEventListener('click', () => { + populateHistoryContainerSelect(); + loadHistory(); + }); })(); From 54196a2d4fc214c0640e2064c0af593b8b9ed5d1 Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 20:28:45 -0700 Subject: [PATCH 08/13] chore: add scripts/release.sh for cutting + publishing releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automates what was previously a six-step manual process that, twice in this codebase's history, has produced version skew between git and the released tarball (v1.2.0 was published with package.json 1.2.0 in the tarball but the bump was never committed back to gitea — making "what code is in v1.2.0?" answerable only by extracting the tarball). The script: - Refuses to run with a dirty tree, off main, or already at the target version. - Bumps dashcaddy-api/package.json, rebuilds status/dist/, commits + pushes to gitea — so the released artifact and gitea HEAD are always in lockstep. - Clones gitea HEAD on the release host, verifies the cloned commit matches what we just pushed (catches a stale clone or a missed push), tars it, computes sha256, writes version.json. - Refreshes install.sh on the release host alongside the tarball (fresh installs use the install.sh from the latest release). - Mirrors the release dir to the get2 backup via rsync. - Verifies live by curling version.json and re-hashing the served tarball. Hosts overridable via DASHCADDY_RELEASE_HOST / DASHCADDY_MIRROR_HOST / DASHCADDY_GITEA_URL env vars. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/release.sh | 133 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 scripts/release.sh diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..0eed420 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Cut a DashCaddy release: bump dashcaddy-api/package.json, commit, push, +# build tarball + version.json on DNS2 (the get.dashcaddy.net publishing +# host), refresh install.sh, then mirror everything to the dc-contabo-de +# get2 backup. +# +# Usage: scripts/release.sh +# Example: scripts/release.sh 1.4.0 +# +# Pre-flight: must be on `main`, working tree clean, gitea remote reachable. +# +# Hosts/URLs are overridable via env: +# DASHCADDY_RELEASE_HOST default root@100.104.4.5 (DNS2, hosts get.dashcaddy.net) +# DASHCADDY_MIRROR_HOST default root@dc-contabo-de (hosts get2.dashcaddy.net) +# DASHCADDY_GITEA_URL default http://100.98.123.59:3000/sami7777/dashcaddy.git + +set -euo pipefail + +VERSION="${1:-}" +[[ -z "$VERSION" ]] && { echo "Usage: $0 " >&2; exit 1; } +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid version (need X.Y.Z): $VERSION" >&2; exit 1; } + +REPO_ROOT="$(git rev-parse --show-toplevel)" +RELEASE_HOST="${DASHCADDY_RELEASE_HOST:-root@100.104.4.5}" +MIRROR_HOST="${DASHCADDY_MIRROR_HOST:-root@dc-contabo-de}" +GITEA_URL="${DASHCADDY_GITEA_URL:-http://100.98.123.59:3000/sami7777/dashcaddy.git}" + +cd "$REPO_ROOT" + +# ── Pre-flight ───────────────────────────────────────────────────────────── +[[ -f dashcaddy-api/package.json ]] || { echo "Run from dashcaddy repo root (no dashcaddy-api/package.json)" >&2; exit 1; } +[[ -n "$(git status --porcelain)" ]] && { echo "Working tree must be clean" >&2; exit 1; } +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +[[ "$BRANCH" == "main" ]] || { echo "Must be on main (current: $BRANCH)" >&2; exit 1; } + +CURRENT="$(node -p "require('./dashcaddy-api/package.json').version")" +[[ "$CURRENT" == "$VERSION" ]] && { echo "package.json already at $VERSION — nothing to do" >&2; exit 1; } + +echo "─── Cutting release ───" +echo " current: $CURRENT" +echo " target: $VERSION" +echo " release: $RELEASE_HOST" +echo " mirror: $MIRROR_HOST" +echo + +# ── 1. Bump dashcaddy-api/package.json ──────────────────────────────────── +echo "[1/6] Bumping dashcaddy-api/package.json" +node -e " + const fs = require('fs'); + const pkg = require('./dashcaddy-api/package.json'); + pkg.version = '$VERSION'; + fs.writeFileSync('./dashcaddy-api/package.json', JSON.stringify(pkg, null, 2) + '\n'); +" + +# ── 2. Rebuild status frontend so dist/*.js matches source ──────────────── +if [[ -f status/build.js ]]; then + echo "[2/6] Rebuilding status frontend" + (cd status && node build.js >/dev/null) +fi + +# ── 3. Commit + push ────────────────────────────────────────────────────── +echo "[3/6] Committing + pushing" +git add dashcaddy-api/package.json +# status/dist/ is .gitignored but its tracked files still need re-staging on rebuild. +# -f bypasses the ignore for these specific paths. +[[ -d status/dist ]] && git add -f status/dist/ 2>/dev/null || true +git commit -m "chore(release): bump to $VERSION" >/dev/null +git push gitea main >/dev/null +COMMIT="$(git rev-parse --short HEAD)" +echo " → committed: $COMMIT" + +# ── 4. Build tarball on the publishing host ─────────────────────────────── +echo "[4/6] Building tarball on $RELEASE_HOST" +ssh "$RELEASE_HOST" "set -e + rm -rf /tmp/dashcaddy-build + mkdir -p /tmp/dashcaddy-build + cd /tmp/dashcaddy-build + git clone --depth 1 '$GITEA_URL' dashcaddy >/dev/null 2>&1 + cd dashcaddy + ACTUAL_COMMIT=\$(git rev-parse --short HEAD) + if [ \"\$ACTUAL_COMMIT\" != \"$COMMIT\" ]; then + echo \" ! cloned commit \$ACTUAL_COMMIT does not match expected $COMMIT — aborting\" >&2 + exit 1 + fi + rm -rf .git + find . -type d -name node_modules -exec rm -rf {} + 2>/dev/null || true + cd /tmp/dashcaddy-build + tar zcf dashcaddy-$VERSION.tar.gz dashcaddy/ +" +echo " → built /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz" + +# ── 5. Publish on the release host ──────────────────────────────────────── +echo "[5/6] Publishing v$VERSION + refreshed install.sh on $RELEASE_HOST" +ssh "$RELEASE_HOST" "set -e + cd /var/www/get.dashcaddy.net + cp -a release release.backup-\$(date -u +%Y%m%d-%H%M%S) + cp /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz release/ + cp /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz release/latest.tar.gz + ( cd release && sha256sum latest.tar.gz > latest.tar.gz.sha256 ) + cp /tmp/dashcaddy-build/dashcaddy/dashcaddy-installer/install.sh release/install.sh + chmod +x release/install.sh + SHA256=\$(sha256sum release/dashcaddy-$VERSION.tar.gz | cut -d' ' -f1) + cat > release/version.json <&2; exit 1; } +echo " get.dashcaddy.net → $SERVED_VER ✓" + +SHA_LOCAL="$(ssh "$RELEASE_HOST" "sha256sum /var/www/get.dashcaddy.net/release/dashcaddy-$VERSION.tar.gz | cut -d' ' -f1")" +SHA_HTTP="$(curl -fsSL --max-time 30 "https://get.dashcaddy.net/release/dashcaddy-$VERSION.tar.gz" | sha256sum | cut -d' ' -f1)" +[[ "$SHA_LOCAL" == "$SHA_HTTP" ]] || { echo "SHA mismatch on served tarball" >&2; exit 1; } +echo " tarball sha256 → $SHA_HTTP ✓" + +echo +echo "Done. v$VERSION published from commit $COMMIT." From 88206ff215534be91d8186ddc33c4292af80da76 Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 20:29:04 -0700 Subject: [PATCH 09/13] chore(release): bump to 1.4.0 --- dashcaddy-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index 03fff99..bd93848 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -1,6 +1,6 @@ { "name": "dashcaddy-api", - "version": "1.3.1", + "version": "1.4.0", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "main": "server.js", "scripts": { From f65af5d7fd4cadc31631bbb31408a19f4c1df48b Mon Sep 17 00:00:00 2001 From: Sami Date: Tue, 12 May 2026 18:20:59 -0700 Subject: [PATCH 10/13] fix(updater): stop false-positive "update available" loop when commit is unknown Dockerfile never received DASHCADDY_COMMIT at build, so /app/VERSION held 'unknown'. _isNewer then treated same-version-different-commit as newer, making the auto-updater rebuild the container indefinitely (each rebuild still produced commit='unknown'). - self-updater._isNewer: normalize commits; treat unknown/null/empty as no commit info and fall back to pure version comparison - self-updater._autoCheckAndApply + routes/updates: refuse to apply when local version >= remote version (belt-and-suspenders) - update-management.js: hide '(unknown)' from version label - Dockerfile: COPY VERSION instead of writing from build arg - VERSION: committed placeholder ('dev'); scripts/release.sh now writes the real short SHA into the tarball's VERSION before tar-ing, so every published release ships with an accurate commit Co-Authored-By: Claude Opus 4.7 (1M context) --- dashcaddy-api/Dockerfile | 6 ++++-- dashcaddy-api/VERSION | 1 + dashcaddy-api/routes/updates.js | 13 +++++++++++-- dashcaddy-api/self-updater.js | 30 +++++++++++++++++++++++++----- scripts/release.sh | 3 +++ status/js/update-management.js | 3 ++- 6 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 dashcaddy-api/VERSION diff --git a/dashcaddy-api/Dockerfile b/dashcaddy-api/Dockerfile index 2680695..6d33bc0 100644 --- a/dashcaddy-api/Dockerfile +++ b/dashcaddy-api/Dockerfile @@ -13,8 +13,10 @@ COPY src/ ./src/ COPY routes/ ./routes/ COPY openapi.yaml ./ -ARG DASHCADDY_COMMIT=unknown -RUN echo "${DASHCADDY_COMMIT}" > VERSION +# VERSION file holds the short git SHA the image was built from. Committed as +# 'dev' for source builds; the release script (scripts/release.sh) overwrites it +# with the actual commit hash before tarballing each release. +COPY VERSION ./ # Note: Running as root because container needs Docker socket access # (which is root-equivalent anyway). Socket access required for container management. diff --git a/dashcaddy-api/VERSION b/dashcaddy-api/VERSION new file mode 100644 index 0000000..38f8e88 --- /dev/null +++ b/dashcaddy-api/VERSION @@ -0,0 +1 @@ +dev diff --git a/dashcaddy-api/routes/updates.js b/dashcaddy-api/routes/updates.js index 62f3507..4211f8e 100644 --- a/dashcaddy-api/routes/updates.js +++ b/dashcaddy-api/routes/updates.js @@ -95,6 +95,15 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError } if (!check.available) { return res.json({ success: true, message: 'Already up to date' }); } + // Refuse same-version applies. The check.available flag can theoretically be + // true with equal versions (commit-mismatch path); applying anyway just + // rebuilds the container without changing anything user-visible and pollutes + // history with v1.4.0 → v1.4.0 entries. + const localV = check.local && check.local.version; + const remoteV = check.remote && check.remote.version; + if (localV && remoteV && localV === remoteV) { + return res.json({ success: true, message: 'Already up to date', version: localV }); + } // Start async — container may restart selfUpdater.applyUpdate(check.remote).catch(err => { logError('self-update', err); @@ -102,8 +111,8 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError } res.json({ success: true, message: 'Update initiated', - fromVersion: check.local.version, - toVersion: check.remote.version, + fromVersion: localV, + toVersion: remoteV, }); }, 'system-update-apply')); diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index 665f5ea..2de8eaf 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -400,10 +400,17 @@ class SelfUpdater extends EventEmitter { async _autoCheckAndApply() { try { const result = await this.checkForUpdate(); - if (result.available && result.remote) { - console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version); - await this.applyUpdate(result.remote); + if (!result.available || !result.remote) return; + // Belt-and-suspenders: never auto-apply a same-version update. A bug here + // creates an infinite rebuild loop (each fresh container has commit='unknown' + // so it keeps comparing as different from remote forever). + if (this._compareVersions(result.local.version, result.remote.version) >= 0) { + console.log('[SelfUpdater] Skipping auto-apply: local %s is not older than remote %s', + result.local.version, result.remote.version); + return; } + console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version); + await this.applyUpdate(result.remote); } catch (e) { console.error('[SelfUpdater] Auto-update error:', e.message); } @@ -496,11 +503,24 @@ class SelfUpdater extends EventEmitter { const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version); if (versionCompare < 0) return true; if (versionCompare > 0) return false; - // Same version — check commit hash - if (remote.commit && local.commit && remote.commit !== local.commit) return true; + // Same version — only flag as newer if BOTH commits are known and differ. + // If local.commit is missing or 'unknown' (Dockerfile default when no build arg + // is passed), we can't distinguish builds, so trust the version number and + // treat them as equivalent. Otherwise the updater loops forever applying + // the same version because every fresh container build has commit='unknown'. + const localCommit = this._normalizeCommit(local.commit); + const remoteCommit = this._normalizeCommit(remote.commit); + if (localCommit && remoteCommit && localCommit !== remoteCommit) return true; return false; } + _normalizeCommit(value) { + if (!value) return null; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'unknown' || str === 'null' || str === 'undefined') return null; + return str; + } + _compareVersions(a, b) { const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); diff --git a/scripts/release.sh b/scripts/release.sh index 0eed420..0ca2163 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -84,6 +84,9 @@ ssh "$RELEASE_HOST" "set -e fi rm -rf .git find . -type d -name node_modules -exec rm -rf {} + 2>/dev/null || true + # Bake the actual commit SHA into the api VERSION file so containers built + # from this tarball report a real commit (not the 'dev' placeholder). + echo \"$COMMIT\" > dashcaddy-api/VERSION cd /tmp/dashcaddy-build tar zcf dashcaddy-$VERSION.tar.gz dashcaddy/ " diff --git a/status/js/update-management.js b/status/js/update-management.js index 2ae6e98..03d5f5c 100644 --- a/status/js/update-management.js +++ b/status/js/update-management.js @@ -335,7 +335,8 @@ const res = await fetch('/api/v1/system/version'); const data = await res.json(); if (data.success) { - dcVersionInfo.textContent = 'v' + data.version + (data.commit ? ' (' + data.commit.substring(0, 7) + ')' : ''); + const commit = data.commit && data.commit !== 'unknown' ? data.commit : null; + dcVersionInfo.textContent = 'v' + data.version + (commit ? ' (' + commit.substring(0, 7) + ')' : ''); } } catch (_) { dcVersionInfo.textContent = 'Unable to fetch version'; From 8ecae8170347f6ac734a197ddf1a5bd732f8d1bf Mon Sep 17 00:00:00 2001 From: Krystie Date: Thu, 14 May 2026 00:13:50 -0700 Subject: [PATCH 11/13] frontend: gate routine console.log behind window.DASHCADDY_DEBUG Suppress noisy module-load and initialization logs in production. Logs still appear when window.DASHCADDY_DEBUG is set (e.g. during dev). --- status/js/dns-template-selector.js | 12 ++++++++---- status/js/onboarding.js | 30 ++++++++++++++++++------------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/status/js/dns-template-selector.js b/status/js/dns-template-selector.js index 845af68..d0f03a8 100644 --- a/status/js/dns-template-selector.js +++ b/status/js/dns-template-selector.js @@ -6,12 +6,16 @@ (function(window) { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) console.log(...args); + }; + class DnsTemplateSelector { constructor(progressTracker) { this.progressTracker = progressTracker; this.modal = null; this.onTemplateSelected = null; - console.log('[DnsTemplateSelector] Module loaded'); + debug('[DnsTemplateSelector] Module loaded'); } /** @@ -216,7 +220,7 @@ * @private */ handleTemplateSelection(template) { - console.log(`[DnsTemplateSelector] Template selected: ${template.id}`); + debug(`[DnsTemplateSelector] Template selected: ${template.id}`); // Close modal this.close(); @@ -235,7 +239,7 @@ * @private */ handleSetupLater() { - console.log('[DnsTemplateSelector] DNS setup deferred'); + debug('[DnsTemplateSelector] DNS setup deferred'); // Mark as deferred in progress tracker if (this.progressTracker) { @@ -316,6 +320,6 @@ } window.DnsTemplateSelector = DnsTemplateSelector; - console.log('[DnsTemplateSelector] Module loaded'); + debug('[DnsTemplateSelector] Module loaded'); })(window); diff --git a/status/js/onboarding.js b/status/js/onboarding.js index f690ade..9ff4d8b 100644 --- a/status/js/onboarding.js +++ b/status/js/onboarding.js @@ -9,6 +9,12 @@ (function() { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { + console.log(...args); + } + }; + let progressTracker; let themeAdapter; let tourManager; @@ -20,7 +26,7 @@ */ async function initializeOnboarding() { try { - console.log('[Onboarding] Initializing system...'); + debug('[Onboarding] Initializing system...'); if (window.__dashcaddySiteConfigLoaded) { try { @@ -30,27 +36,27 @@ // Initialize Error Handler first errorHandler = new ErrorHandler(); - console.log('[Onboarding] Error Handler initialized'); + debug('[Onboarding] Error Handler initialized'); // Initialize Progress Tracker progressTracker = new ProgressTracker('dashcaddy_onboarding'); - console.log('[Onboarding] Progress Tracker initialized'); + debug('[Onboarding] Progress Tracker initialized'); // Initialize Theme Adapter themeAdapter = new ThemeAdapter(); - console.log('[Onboarding] Theme Adapter initialized'); + debug('[Onboarding] Theme Adapter initialized'); // Initialize DNS Template Selector dnsTemplateSelector = new DnsTemplateSelector(progressTracker); - console.log('[Onboarding] DNS Template Selector initialized'); + debug('[Onboarding] DNS Template Selector initialized'); // Initialize Tour Manager tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); - console.log('[Onboarding] Tour Manager initialized'); + debug('[Onboarding] Tour Manager initialized'); // Check if tour should auto-start if (tourManager.shouldAutoStart()) { - console.log('[Onboarding] Auto-starting tour for first-time install'); + debug('[Onboarding] Auto-starting tour for first-time install'); await progressTracker.markInstallOnboardingCompleted(); // Wait a bit for page to fully load setTimeout(() => { @@ -59,11 +65,11 @@ } else { const tourCompleted = progressTracker.isTourCompleted(); const currentStep = progressTracker.getCurrentStep(); - console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); + debug(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); // If tour is in progress, offer to resume if (!tourCompleted && currentStep > 0) { - console.log('[Onboarding] Tour in progress, can be resumed manually'); + debug('[Onboarding] Tour in progress, can be resumed manually'); } } @@ -81,7 +87,7 @@ getErrorStats: () => errorHandler.getStatistics() }; - console.log('[Onboarding] System initialized successfully'); + debug('[Onboarding] System initialized successfully'); } catch (error) { console.error('[Onboarding] Initialization error:', error); @@ -104,7 +110,7 @@ const clickHandler = () => { if (tourManager) { - console.log('[Onboarding] Starting tour via button click'); + debug('[Onboarding] Starting tour via button click'); tourManager.restartTour(); } else { console.error('[Onboarding] Tour manager not initialized'); @@ -179,6 +185,6 @@ waitForDriver(); } - console.log('[Onboarding] System loaded'); + debug('[Onboarding] System loaded'); })(); From 5d404f57335773e673273567e7191cd6e5626532 Mon Sep 17 00:00:00 2001 From: Krystie Date: Thu, 14 May 2026 00:48:51 -0700 Subject: [PATCH 12/13] cleanup: wrap console.log calls behind window.DASHCADDY_DEBUG flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapped 22 console.log calls across 6 files with a debug() helper that only logs when window.DASHCADDY_DEBUG is true in the browser console. Files: - tour-manager.js: 10 calls - theme-adapter.js: 4 calls - keyboard-shortcuts.js: 4 calls - tooltip-definitions.js: 2 calls - progress-tracker.js: 1 call - live-events.js: 1 call console.error and console.warn calls preserved — those indicate real issues worth seeing in production. --- status/js/keyboard-shortcuts.js | 12 ++++++++---- status/js/live-events.js | 2 +- status/js/progress-tracker.js | 6 +++++- status/js/theme-adapter.js | 12 ++++++++---- status/js/tooltip-definitions.js | 8 ++++++-- status/js/tour-manager.js | 24 ++++++++++++++---------- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/status/js/keyboard-shortcuts.js b/status/js/keyboard-shortcuts.js index 2080750..091ead3 100644 --- a/status/js/keyboard-shortcuts.js +++ b/status/js/keyboard-shortcuts.js @@ -6,6 +6,10 @@ (function() { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + // All modal selectors that can be closed with Escape const MODAL_SELECTORS = [ '#app-selector-modal', @@ -39,9 +43,9 @@ // Add global keyboard listener document.addEventListener('keydown', handleKeyDown); - console.log('[Keyboard Shortcuts] Initialized'); - console.log('[Keyboard Shortcuts] Press Ctrl+K to open quick search'); - console.log('[Keyboard Shortcuts] Press Escape to close modals'); + debug('[Keyboard Shortcuts] Initialized'); + debug('[Keyboard Shortcuts] Press Ctrl+K to open quick search'); + debug('[Keyboard Shortcuts] Press Escape to close modals'); } catch (e) { console.warn('[Keyboard Shortcuts] Failed to initialize:', e.message); } @@ -599,7 +603,7 @@ } break; default: - console.log('[Keyboard Shortcuts] Unknown action:', action); + debug('[Keyboard Shortcuts] Unknown action:', action); } } catch (e) { console.warn('[Keyboard Shortcuts] Error executing action:', e.message); diff --git a/status/js/live-events.js b/status/js/live-events.js index 7a54ebd..0eb8e75 100644 --- a/status/js/live-events.js +++ b/status/js/live-events.js @@ -11,7 +11,7 @@ es.addEventListener('connected', () => { reconnectDelay = 1000; // reset backoff - console.log('[SSE] Connected to event stream'); + debug('[SSE] Connected to event stream'); }); // Health status changes → update card dots/badges in real time diff --git a/status/js/progress-tracker.js b/status/js/progress-tracker.js index 9fb2757..035790a 100644 --- a/status/js/progress-tracker.js +++ b/status/js/progress-tracker.js @@ -18,6 +18,10 @@ (function(window) { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + /** * ProgressTracker class * Manages persistent storage of onboarding progress @@ -308,6 +312,6 @@ // Export to global scope window.ProgressTracker = ProgressTracker; - console.log('[ProgressTracker] Module loaded'); + debug('[ProgressTracker] Module loaded'); })(window); diff --git a/status/js/theme-adapter.js b/status/js/theme-adapter.js index e79ae88..3e45e63 100644 --- a/status/js/theme-adapter.js +++ b/status/js/theme-adapter.js @@ -7,6 +7,10 @@ (function(window) { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + /** * Theme configuration mapping for Driver.js * Maps dashboard themes to Driver.js styling @@ -170,7 +174,7 @@ attributeFilter: ['class'] }); - console.log('[ThemeAdapter] Theme change listener initialized'); + debug('[ThemeAdapter] Theme change listener initialized'); } /** @@ -180,7 +184,7 @@ * @param {string} oldTheme - Old theme name */ _notifyThemeChange(newTheme, oldTheme) { - console.log(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); + debug(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); this.themeChangeCallbacks.forEach(callback => { try { @@ -207,7 +211,7 @@ // Note: Driver.js v1.0+ uses CSS variables, so we inject a style element this._injectDriverStyles(themeConfig); - console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme); + debug('[ThemeAdapter] Theme applied to driver:', this.currentTheme); } /** @@ -302,6 +306,6 @@ // Export to global scope window.ThemeAdapter = ThemeAdapter; - console.log('[ThemeAdapter] Module loaded'); + debug('[ThemeAdapter] Module loaded'); })(window); diff --git a/status/js/tooltip-definitions.js b/status/js/tooltip-definitions.js index 970b583..0509499 100644 --- a/status/js/tooltip-definitions.js +++ b/status/js/tooltip-definitions.js @@ -6,6 +6,10 @@ (function(window) { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + /** * Validate a tooltip definition * @param {Object} tooltip - The tooltip definition to validate @@ -160,7 +164,7 @@ TooltipError }; - console.log('[TooltipDefinitions] Validation module loaded'); + debug('[TooltipDefinitions] Validation module loaded'); })(window); @@ -534,5 +538,5 @@ window.TooltipDefinitions = { getNewFeatureTooltips }; -console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); +debug('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); diff --git a/status/js/tour-manager.js b/status/js/tour-manager.js index 7b60d2a..d1b92a6 100644 --- a/status/js/tour-manager.js +++ b/status/js/tour-manager.js @@ -6,6 +6,10 @@ (function(window) { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + class TourManager { constructor(progressTracker, themeAdapter, dnsTemplateSelector) { this.progressTracker = progressTracker; @@ -92,7 +96,7 @@ const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); if (activeTooltips.length === 0) { - console.log('[TourManager] No tooltips to show'); + debug('[TourManager] No tooltips to show'); this.progressTracker.markTourCompleted(); return; } @@ -131,7 +135,7 @@ // Add custom handlers for DNS tooltip if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { step.popover.onSetupNowClick = () => { - console.log('[TourManager] Opening DNS template selector'); + debug('[TourManager] Opening DNS template selector'); this.dnsTemplateSelector.showTemplateSelector(); // Mark tooltip as completed and move to next this.progressTracker.markTooltipCompleted(tooltip.id); @@ -141,7 +145,7 @@ }; step.popover.onLaterClick = () => { - console.log('[TourManager] DNS setup deferred'); + debug('[TourManager] DNS setup deferred'); this.progressTracker.markDnsSetupDeferred(); // Mark tooltip as completed and move to next this.progressTracker.markTooltipCompleted(tooltip.id); @@ -232,11 +236,11 @@ const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); if (newFeatureTooltips.length === 0) { - console.log('[TourManager] No new features to show'); + debug('[TourManager] No new features to show'); return; } - console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); + debug(`[TourManager] Showing ${newFeatureTooltips.length} new features`); // Convert to Driver.js steps const steps = newFeatureTooltips.map((tooltip, index) => { @@ -280,7 +284,7 @@ clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (this.isActive && this.driver) { - console.log('[TourManager] Window resized, repositioning tooltip'); + debug('[TourManager] Window resized, repositioning tooltip'); this.driver.refresh(); } }, 150); // Debounce for 150ms @@ -289,7 +293,7 @@ // Layout change handler (for theme changes, DOM mutations) this.layoutChangeHandler = () => { if (this.isActive && this.driver) { - console.log('[TourManager] Layout changed, repositioning tooltip'); + debug('[TourManager] Layout changed, repositioning tooltip'); // Small delay to allow layout to settle setTimeout(() => { if (this.driver) { @@ -347,7 +351,7 @@ onTourComplete() { this.progressTracker.markTourCompleted(); this.isActive = false; - console.log('[TourManager] Tour completed'); + debug('[TourManager] Tour completed'); } /** @@ -355,12 +359,12 @@ */ onTourSkip() { // Save current progress but don't mark as completed - console.log('[TourManager] Tour skipped'); + debug('[TourManager] Tour skipped'); this.isActive = false; } } window.TourManager = TourManager; - console.log('[TourManager] Module loaded'); + debug('[TourManager] Module loaded'); })(window); From f60a3370db98e36134a37321de3d930e55403085 Mon Sep 17 00:00:00 2001 From: Krystie Date: Thu, 14 May 2026 01:31:55 -0700 Subject: [PATCH 13/13] fix: route all console.error through ErrorHandler for consistent tracking Converted 35+ raw console.error calls to use ErrorHandler.logError() across 12 files. ErrorHandler provides structured logging, local error storage, and error tracking integration. Files: - app-selector.js: 4 errors (fetch templates, port check, suggested port, deploy) - globals.js: 2 errors (CSRF token get/add) - service-credentials.js: 2 errors (save/clear credentials) - totp-settings.js: 4 errors (TOTP setup, session duration, disable, AuthCard) - notification-settings.js: 2 errors (load config, load history) - setup-wizard.js: 2 errors (save config to server) - progress-tracker.js: 4 errors (storage read/write/session/fallback) - tooltip-definitions.js: 2 errors (validation, condition eval) - tour-manager.js: 2 errors (Driver.js not loaded, tooltip not found) - theme-adapter.js: 1 error (theme change callback) - weather.js: 1 error (weather update) - onboarding.js: removed duplicate console.error, fixed 2 remaining calls All console.error calls in the DashCaddy frontend now go through ErrorHandler. --- status/js/app-selector.js | 9 +++++---- status/js/globals.js | 7 +++++-- status/js/notification-settings.js | 5 +++-- status/js/onboarding.js | 10 ++++------ status/js/progress-tracker.js | 10 ++++++---- status/js/service-credentials.js | 5 +++-- status/js/setup-wizard.js | 6 ++++-- status/js/theme-adapter.js | 4 +++- status/js/tooltip-definitions.js | 6 ++++-- status/js/totp-settings.js | 9 +++++---- status/js/tour-manager.js | 6 ++++-- status/js/weather.js | 3 ++- 12 files changed, 48 insertions(+), 32 deletions(-) diff --git a/status/js/app-selector.js b/status/js/app-selector.js index 60c7c4a..3855161 100644 --- a/status/js/app-selector.js +++ b/status/js/app-selector.js @@ -1,5 +1,6 @@ // App Selector System (function () { + const errorHandler = new ErrorHandler(); injectModal('app-selector-modal', `

Choose an App

@@ -230,7 +231,7 @@ return true; } } catch (e) { - console.error('Failed to fetch app templates:', e); + errorHandler.logError('[AppSelector] Fetch Templates', e, { function: 'fetchApiTemplates' }); } return false; } @@ -242,7 +243,7 @@ const data = await response.json(); return data; } catch (e) { - console.error('Failed to check port:', e); + errorHandler.logError('[AppSelector] Check Port', e, { function: 'checkPortAvailability' }); return { available: true }; // Assume available on error } } @@ -256,7 +257,7 @@ return data.suggestedPort; } } catch (e) { - console.error('Failed to get suggested port:', e); + errorHandler.logError('[AppSelector] Get Suggested Port', e, { function: 'getSuggestedPort' }); } return basePort; } @@ -842,7 +843,7 @@ throw new Error(result.error || 'Deployment failed'); } } catch (error) { - console.error('Deployment error:', error); + errorHandler.logError('[AppSelector] Deployment', error, { function: 'deploy' }); showNotification( `Failed to deploy ${appTemplate.name}: ${error.message}`, 'error', diff --git a/status/js/globals.js b/status/js/globals.js index 8153266..31d4642 100644 --- a/status/js/globals.js +++ b/status/js/globals.js @@ -24,6 +24,9 @@ const DC = { }, }; +// Error handler for tracking issues +const errorHandler = new ErrorHandler(); + // ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) ===== // Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null'); @@ -160,7 +163,7 @@ async function getCSRFToken() { csrfToken = data.token; return csrfToken; } catch (error) { - console.error('Failed to get CSRF token:', error); + errorHandler.logError('[CSRF] Get Token', error, { function: 'getCSRFToken' }); throw error; } } @@ -185,7 +188,7 @@ async function secureFetch(url, options = {}) { 'X-CSRF-Token': token }; } catch (error) { - console.error('Failed to add CSRF token to request:', error); + errorHandler.logError('[CSRF] Add to Request', error, { function: 'secureFetch' }); } } diff --git a/status/js/notification-settings.js b/status/js/notification-settings.js index 636630e..8d41809 100644 --- a/status/js/notification-settings.js +++ b/status/js/notification-settings.js @@ -1,5 +1,6 @@ // ========== NOTIFICATION SETTINGS ========== (function() { + const errorHandler = new ErrorHandler(); // Inject modal HTML injectModal('notifications-modal', `
@@ -255,7 +256,7 @@ document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false; } } catch (error) { - console.error('Failed to load notification config:', error); + errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' }); } } @@ -289,7 +290,7 @@ container.innerHTML = '
No notifications yet
'; } } catch (error) { - console.error('Failed to load notification history:', error); + errorHandler.logError('[Notifications] Load History', error, { function: 'loadHistory' }); } } diff --git a/status/js/onboarding.js b/status/js/onboarding.js index 9ff4d8b..6a793bd 100644 --- a/status/js/onboarding.js +++ b/status/js/onboarding.js @@ -89,11 +89,8 @@ debug('[Onboarding] System initialized successfully'); } catch (error) { - console.error('[Onboarding] Initialization error:', error); - - // Use error handler if available if (errorHandler) { - errorHandler.logError('Initialization', error); + errorHandler.logError('[Onboarding] Initialization', error); } // Graceful degradation - don't break the dashboard @@ -113,7 +110,9 @@ debug('[Onboarding] Starting tour via button click'); tourManager.restartTour(); } else { - console.error('[Onboarding] Tour manager not initialized'); + if (errorHandler) { + errorHandler.logError('[Onboarding] Tour Manager Not Initialized', new Error('Tour manager not initialized')); + } alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); } }; @@ -163,7 +162,6 @@ setTimeout(attemptInit, 500); } else { // Max retries reached, show fallback - console.error('[Onboarding] Driver.js failed to load after multiple attempts'); if (errorHandler) { errorHandler.handleDriverLoadFailure(); } else { diff --git a/status/js/progress-tracker.js b/status/js/progress-tracker.js index 035790a..8982406 100644 --- a/status/js/progress-tracker.js +++ b/status/js/progress-tracker.js @@ -18,6 +18,8 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + const debug = (...args) => { if (window.DASHCADDY_DEBUG) { console.log(...args); } }; @@ -72,7 +74,7 @@ const data = localStorage.getItem(this.storageKey); return data ? JSON.parse(data) : null; } catch (error) { - console.error('[ProgressTracker] Error reading from storage:', error); + errorHandler.logError('[ProgressTracker] Read Storage', error, { function: '_getStorage' }); return null; } } @@ -86,7 +88,7 @@ try { localStorage.setItem(this.storageKey, JSON.stringify(state)); } catch (error) { - console.error('[ProgressTracker] Error writing to storage:', error); + errorHandler.logError('[ProgressTracker] Write Storage', error, { function: '_setStorage' }); // Handle quota exceeded or storage unavailable // Fall back to session storage or in-memory storage this._handleStorageError(error); @@ -104,7 +106,7 @@ sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); console.warn('[ProgressTracker] Falling back to session storage'); } catch (sessionError) { - console.error('[ProgressTracker] Session storage also unavailable:', sessionError); + errorHandler.logError('[ProgressTracker] Session Storage Unavailable', sessionError, { function: '_handleStorageError' }); // Could implement in-memory fallback here if needed } } @@ -190,7 +192,7 @@ body: JSON.stringify({ onboardingCompleted: true }) }); } catch (error) { - console.error('[ProgressTracker] Failed to persist install onboarding state:', error); + errorHandler.logError('[ProgressTracker] Persist Install Onboarding', error, { function: 'markInstallOnboardingCompleted' }); } } diff --git a/status/js/service-credentials.js b/status/js/service-credentials.js index e5ddb06..0186b74 100644 --- a/status/js/service-credentials.js +++ b/status/js/service-credentials.js @@ -1,5 +1,6 @@ // ===== SERVICE CREDENTIALS ===== (function() { + const errorHandler = new ErrorHandler(); injectModal('folder-browser-modal', `

📂 Browse for Media Folders

@@ -433,7 +434,7 @@ await loadServiceCreds(currentService); } catch (e) { - console.error('Failed to save credentials:', e); + errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' }); showError('Failed to save: ' + (e.message || 'Unknown error')); } saveBtn.textContent = 'Save'; @@ -460,7 +461,7 @@ if (btn) btn.classList.remove('has-creds'); await loadServiceCreds(currentService); } catch (e) { - console.error('Failed to clear credentials:', e); + errorHandler.logError('[ServiceCredentials] Clear', e, { function: 'clearCredentials' }); showError('Failed to clear: ' + (e.message || 'Unknown error')); } }); diff --git a/status/js/setup-wizard.js b/status/js/setup-wizard.js index 15a396d..c6dd957 100644 --- a/status/js/setup-wizard.js +++ b/status/js/setup-wizard.js @@ -1,4 +1,6 @@ // Shared timezone utility — used by setup wizard and settings modal +const errorHandler = new ErrorHandler(); + window.populateTimezoneSelect = function(selectEl, selectedTz) { const timezones = Intl.supportedValuesOf('timeZone'); const detected = selectedTz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; @@ -150,11 +152,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) { await response.json(); return true; } else { - console.error('Failed to save config to server:', response.status); + errorHandler.logError('[SetupWizard] Save Config', new Error(`Server returned ${response.status}`), { function: 'saveConfigToServer' }); return false; } } catch (error) { - console.error('Error saving config to server:', error); + errorHandler.logError('[SetupWizard] Save Config', error, { function: 'saveConfigToServer' }); return false; } } diff --git a/status/js/theme-adapter.js b/status/js/theme-adapter.js index 3e45e63..b0afc40 100644 --- a/status/js/theme-adapter.js +++ b/status/js/theme-adapter.js @@ -7,6 +7,8 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + const debug = (...args) => { if (window.DASHCADDY_DEBUG) { console.log(...args); } }; @@ -190,7 +192,7 @@ try { callback(newTheme, oldTheme); } catch (error) { - console.error('[ThemeAdapter] Error in theme change callback:', error); + errorHandler.logError('[ThemeAdapter] Theme Change Callback', error, { function: '_notifyThemeChange' }); } }); } diff --git a/status/js/tooltip-definitions.js b/status/js/tooltip-definitions.js index 0509499..215bd89 100644 --- a/status/js/tooltip-definitions.js +++ b/status/js/tooltip-definitions.js @@ -6,6 +6,8 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + const debug = (...args) => { if (window.DASHCADDY_DEBUG) { console.log(...args); } }; @@ -151,7 +153,7 @@ `${e.tooltip}: ${e.errors.join(', ')}` ).join('\n'); - console.error('[TooltipDefinitions] Validation errors:', errorMessages); + errorHandler.logError('[TooltipDefinitions] Validation', errorMessages, { function: 'validateTooltip' }); throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); } } @@ -493,7 +495,7 @@ function getActiveTooltips() { try { return tooltip.condition(); } catch (error) { - console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); + errorHandler.logError('[TooltipDefinitions] Condition Eval', error, { function: 'evaluateCondition', tooltipId: tooltip.id }); return false; } } diff --git a/status/js/totp-settings.js b/status/js/totp-settings.js index c326177..b4e54e5 100644 --- a/status/js/totp-settings.js +++ b/status/js/totp-settings.js @@ -1,5 +1,6 @@ // ===== TOTP SETTINGS ===== (function() { + const errorHandler = new ErrorHandler(); injectModal('totp-settings-modal', `

Authentication Settings

@@ -185,7 +186,7 @@ document.getElementById('totp-setup-code').focus(); } } catch (e) { - console.error('TOTP setup failed:', e); + errorHandler.logError('[TOTP] Setup Failed', e, { function: 'setupTOTP' }); } }); @@ -274,7 +275,7 @@ }); loadTotpSettings(); // Refresh modal + card (handles "never" disabling TOTP) } catch (err) { - console.error('Failed to update session duration:', err); + errorHandler.logError('[TOTP] Update Session Duration', err, { function: 'updateSessionDuration' }); } }); @@ -290,7 +291,7 @@ const data = await res.json(); if (data.success) loadTotpSettings(); } catch (e) { - console.error('Failed to disable TOTP:', e); + errorHandler.logError('[TOTP] Disable Failed', e, { function: 'disableTOTP' }); } }); @@ -322,7 +323,7 @@ const active = data.config.enabled && data.config.isSetUp; updateAuthCard(active, data.config.sessionDuration); } - } catch (e) { console.error('[AuthCard] Failed to update:', e); } + } catch (e) { errorHandler.logError('[TOTP] AuthCard Update', e, { function: 'authCardUpdate' }); } })(); })(); diff --git a/status/js/tour-manager.js b/status/js/tour-manager.js index d1b92a6..c145d7f 100644 --- a/status/js/tour-manager.js +++ b/status/js/tour-manager.js @@ -6,6 +6,8 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + const debug = (...args) => { if (window.DASHCADDY_DEBUG) { console.log(...args); } }; @@ -30,7 +32,7 @@ const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; if (typeof driverFactory !== 'function') { - console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver); + errorHandler.logError('[TourManager] Driver.js Not Loaded', new Error('Driver.js not loaded or invalid'), { windowDriver: typeof window.driver }); return false; } @@ -202,7 +204,7 @@ async showTooltip(tooltipId) { const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); if (!tooltip) { - console.error(`[TourManager] Tooltip not found: ${tooltipId}`); + errorHandler.logError('[TourManager] Tooltip Not Found', new Error(`Tooltip not found: ${tooltipId}`), { tooltipId }); return; } diff --git a/status/js/weather.js b/status/js/weather.js index b439a2d..1d235a6 100644 --- a/status/js/weather.js +++ b/status/js/weather.js @@ -1,5 +1,6 @@ // ========== WEATHER WIDGET ========== (function() { + const errorHandler = new ErrorHandler(); // Inject modal HTML injectModal('weather-modal', `

Weather Settings

@@ -166,7 +167,7 @@ weatherWidget.icon.innerHTML = `${escapeHtml(weather.icon)}`; } } catch (error) { - console.error('Weather update error:', error); + errorHandler.logError('[Weather] Update Error', error, { function: 'updateWeather' }); weatherWidget.location.textContent = 'Weather Error'; weatherWidget.temp.textContent = 'Error'; weatherWidget.condition.textContent = 'Failed to load';