From f537a0dd25bd002f173f20e159e331b0609afb0b Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 6 May 2026 19:14:38 -0700 Subject: [PATCH] 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(); + }); })();