Compare commits

..
3 Commits
Author SHA1 Message Date
Hermes 7f0d43943c feat: restore monitoring widget + add sami-files template
CI / Security audit (push) Has been cancelled
CI / Test & Lint (push) Has been cancelled
- Recreate status/js/monitoring-widgets.js with robust services count
  (reads from window.APPS, #cards DOM, then live fetch as fallback)
- Add sami-files service to data/services.json (Sami Files card)
- Add sami-files template to app-templates.js under 'Files' category
  with full systemd deployment docs and Caddy snippet
- Bundle monitoring-widgets.js into init.js
2026-06-18 18:52:48 -07:00
Krystie 9ab947a394 feat: enforceStorageLimit - prune oldest backups when maxStorageBytes exceeded 2026-05-28 15:14:59 -07:00
Krystie ad9400490d Merge: resolve conflict in routes/backups.js, keep storage-info + maxStorageBytes 2026-05-28 15:00:41 -07:00
39 changed files with 796 additions and 4266 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.9.0 dev
+68
View File
@@ -2459,6 +2459,74 @@ const APP_TEMPLATES = {
"World data is persisted in the data volume", "World data is persisted in the data volume",
"Requires at least 4GB RAM for smooth operation" "Requires at least 4GB RAM for smooth operation"
] ]
},
// === FILE MANAGEMENT — HOST-SERVICE TEMPLATES ===
// Sami Files is a host-systemd service, NOT a Docker container. The
// template exists so users get the right metadata + category in the App
// Selector, but the actual deployment is via `deploy/sami-files.service`
// unit + a Caddy reverse_proxy (see README in /opt/sami-files/deploy/).
// Service health is checked by probing the FastAPI /api/health endpoint
// on 127.0.0.1:8765; Caddy proxies the public URL.
"sami-files": {
name: "Sami Files",
description: "Multi-server SSH file manager — browse, edit, upload, and exec across all your machines from one browser tab",
icon: "📂",
logo: "/assets/sami-files.png",
category: "Files",
popularity: 80,
difficulty: "Intermediate",
isSystemdService: true,
systemdUnit: "sami-files.service",
healthCheck: "http://127.0.0.1:8765/api/health",
healthCheckExpect: "ok",
defaultPort: 8765,
subdomain: "files",
proxyPass: "http://127.0.0.1:8765",
subpathSupport: 'none',
externalConfig: {
// Where the source code / config lives on the host. DashCaddy reads
// these paths when generating a fresh setup via "Deploy" in the App
// Selector — they are informational for the systemd variant.
installDir: "/opt/sami-files",
configFile: "/opt/sami-files/config/servers.yaml",
serviceFile: "/opt/sami-files/deploy/sami-files.service",
logFile: "/opt/sami-files/logs/backend.log",
pythonVenv: "/usr/local/lib/hermes-agent/venv",
repo: "git.sami/sami7777/sami-files",
dependencies: [
"python3 >= 3.11 with uvicorn + asyncssh + pyyaml + fastapi",
"systemd >= 245 (for StandardOutput=append: journal syntax)"
],
caddySnippet: [
"files.sami {",
" reverse_proxy 127.0.0.1:8765",
" import dashcaddy_auth",
"}"
]
},
setupInstructions: [
"Clone the repo: git clone http://100.81.59.99:3030/sami7777/sami-files.git /opt/sami-files",
"Create venv and install deps: /usr/local/lib/hermes-agent/venv/bin/pip install fastapi uvicorn asyncssh pyyaml python-multipart",
"Copy deploy/sami-files.service to /etc/systemd/system/ and `systemctl daemon-reload`",
"Enable + start: systemctl enable --now sami-files.service",
"Edit /opt/sami-files/config/servers.yaml to add your SSH targets",
"Add the Caddy snippet (above) to your Caddyfile and reload Caddy",
"Browse to https://files.sami — log in via DashCaddy SSO"
],
troubleshooting: [
{
symptom: "Service fails to start with 'No such file or directory'",
fix: "Verify the python venv path in the .service file matches your installation (use `which python3` and update ExecStart accordingly)."
},
{
symptom: "Backend logs show 'Permission denied' on key file",
fix: "Run `chmod 600 /root/.ssh/<key>` for each key_file listed in servers.yaml — backend refuses to load keys with looser permissions."
},
{
symptom: "Browser shows 'Cannot connect' but systemctl says running",
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
}
]
} }
}; };
-503
View File
@@ -1,503 +0,0 @@
/**
* Auto-Restart Manager - Per-container restart policies with retry tracking
*
* When a container goes down, attempts automatic restart up to N times
* (configurable per-service). Sends notifications on each attempt and
* when max retries are exceeded. Integrates with HealthChecker events.
*
* @module auto-restart-manager
*/
const EventEmitter = require('events');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
/**
* Default policy values applied when a new policy is created.
* @readonly
*/
const DEFAULT_POLICY = {
enabled: true,
maxRetries: 3,
retryIntervalMs: 5000,
windowMinutes: 10,
currentRetries: 0,
lastRestartAt: null,
cooldownUntil: null,
};
/**
* Manages automatic container restart policies and execution.
*
* @extends EventEmitter
*
* @fires AutoRestartManager#auto-restart-attempt
* @fires AutoRestartManager#auto-restart-success
* @fires AutoRestartManager#auto-restart-failed
* @fires AutoRestartManager#auto-restart-max-reached
*/
class AutoRestartManager extends EventEmitter {
/**
* @param {Object} ctx - Shared application context
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
* @param {Object} ctx.healthChecker - HealthChecker singleton
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {Function} ctx.logError - Error logging function
* @param {string} ctx.SERVICES_FILE - Path to services.json (used to derive data dir)
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_ctx, err) => console.error(err));
this.docker = ctx.docker;
this.healthChecker = ctx.healthChecker;
this.notification = ctx.notification;
/** @type {Map<string, Object>} serviceId -> policy */
this.policies = new Map();
/** Path to the JSON file that persists policies */
this.policiesFile = path.join(path.dirname(ctx.SERVICES_FILE), 'auto-restart-policies.json');
/** Track previous health status per service for transition detection */
this._previousHealth = new Map();
/** Bound handlers so we can remove them on stop() */
this._onStatusCheck = this._handleStatusCheck.bind(this);
this._started = false;
}
// ─── Lifecycle ────────────────────────────────────────────────────────
/**
* Load persisted policies, then wire into HealthChecker events.
* @returns {Promise<void>}
*/
async start() {
if (this._started) return;
// Load persisted policies from disk
try {
const data = await readJsonFile(this.policiesFile, {});
for (const [serviceId, policy] of Object.entries(data)) {
this.policies.set(serviceId, { ...DEFAULT_POLICY, ...policy });
}
this.log.info('auto-restart', 'Policies loaded', { count: this.policies.size });
} catch (err) {
this.log.error('auto-restart', 'Failed to load policies', { error: err.message });
}
// Listen to health checker status transitions
if (this.healthChecker) {
this.healthChecker.on('status-check', this._onStatusCheck);
}
this._started = true;
this.log.info('auto-restart', 'Manager started');
}
/**
* Remove event listeners and stop processing health events.
*/
stop() {
if (!this._started) return;
if (this.healthChecker) {
this.healthChecker.removeListener('status-check', this._onStatusCheck);
}
this._started = false;
this.log.info('auto-restart', 'Manager stopped');
}
// ─── Policy CRUD ─────────────────────────────────────────────────────
/**
* Create or update a restart policy for a service.
*
* @param {string} serviceId - Unique service identifier
* @param {Object} policy - Partial policy fields to merge
* @param {boolean} [policy.enabled=true]
* @param {number} [policy.maxRetries=3]
* @param {number} [policy.retryIntervalMs=5000]
* @param {number} [policy.windowMinutes=10]
* @returns {Promise<Object>} The resulting policy
* @throws {Error} If serviceId is invalid
*/
async setPolicy(serviceId, policy) {
if (!serviceId || typeof serviceId !== 'string') {
throw new Error('serviceId is required');
}
const existing = this.policies.get(serviceId) || { ...DEFAULT_POLICY, serviceId };
const merged = {
...existing,
...policy,
serviceId,
// Never allow caller to override runtime counters directly
currentRetries: existing.currentRetries || 0,
lastRestartAt: existing.lastRestartAt,
cooldownUntil: existing.cooldownUntil,
};
this.policies.set(serviceId, merged);
await this._savePolicies();
this.log.info('auto-restart', 'Policy set', { serviceId, enabled: merged.enabled });
return { ...merged };
}
/**
* Retrieve the policy for a service.
*
* @param {string} serviceId
* @returns {Object|null} Policy object or null if none exists
*/
getPolicy(serviceId) {
const policy = this.policies.get(serviceId);
return policy ? { ...policy } : null;
}
/**
* Return all policies as an array.
* @returns {Object[]}
*/
listPolicies() {
return Array.from(this.policies.values()).map(p => ({ ...p }));
}
/**
* Remove a service's restart policy.
*
* @param {string} serviceId
* @returns {Promise<boolean>} true if a policy was removed
*/
async removePolicy(serviceId) {
if (!this.policies.has(serviceId)) return false;
this.policies.delete(serviceId);
await this._savePolicies();
this.log.info('auto-restart', 'Policy removed', { serviceId });
return true;
}
// ─── Core Restart Logic ──────────────────────────────────────────────
/**
* Called when a container is detected as down.
*
* Checks policy, cooldown, and retry count, then either attempts a
* Docker restart or notifies that max retries were exceeded.
*
* @param {string} serviceId - Service identifier
* @param {string} containerId - Docker container ID to restart
* @returns {Promise<Object>} Result of the operation
*/
async handleContainerDown(serviceId, containerId) {
const policy = this.policies.get(serviceId);
if (!policy) {
return { action: 'ignored', reason: 'no-policy' };
}
if (!policy.enabled) {
return { action: 'ignored', reason: 'disabled' };
}
// Check cooldown window
const now = Date.now();
if (policy.cooldownUntil && now < policy.cooldownUntil) {
this.log.info('auto-restart', 'Skipping — cooldown active', {
serviceId,
cooldownUntil: new Date(policy.cooldownUntil).toISOString(),
});
return { action: 'skipped', reason: 'cooldown' };
}
// Max retries exceeded — notify and enter cooldown
if (policy.currentRetries >= policy.maxRetries) {
const cooldownMs = policy.windowMinutes * 60 * 1000;
policy.cooldownUntil = now + cooldownMs;
policy.currentRetries = 0; // Reset so next window can try again
await this._savePolicies();
const eventData = {
serviceId,
containerId,
maxRetries: policy.maxRetries,
cooldownUntil: policy.cooldownUntil,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-max-reached
* @type {Object}
*/
this.emit('auto-restart-max-reached', eventData);
// Send notification
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `⛔ Max auto-restart retries (${policy.maxRetries}) exceeded for "${serviceId}". Cooldown until ${new Date(policy.cooldownUntil).toISOString()}.`,
...eventData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
return { action: 'max-reached', ...eventData };
}
// Wait for the configured retry interval before attempting
if (policy.retryIntervalMs > 0 && policy.lastRestartAt) {
const elapsed = now - new Date(policy.lastRestartAt).getTime();
if (elapsed < policy.retryIntervalMs) {
const waitMs = policy.retryIntervalMs - elapsed;
this.log.info('auto-restart', 'Waiting for retry interval', { serviceId, waitMs });
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
// Attempt restart
policy.currentRetries += 1;
const attemptNum = policy.currentRetries;
const maxRetries = policy.maxRetries;
/**
* @event AutoRestartManager#auto-restart-attempt
* @type {Object}
*/
this.emit('auto-restart-attempt', {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
timestamp: new Date().toISOString(),
});
try {
if (!this.docker?.client) {
throw new Error('Docker client not available');
}
const container = this.docker.client.getContainer(containerId);
await container.start();
policy.lastRestartAt = new Date().toISOString();
await this._savePolicies();
const successData = {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-success
* @type {Object}
*/
this.emit('auto-restart-success', successData);
// Notify
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `🔄 Auto-restart attempt ${attemptNum}/${maxRetries} succeeded for "${serviceId}".`,
...successData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
this.log.info('auto-restart', 'Container restarted', {
serviceId,
attempt: attemptNum,
maxRetries,
});
return { action: 'restarted', ...successData };
} catch (restartErr) {
policy.lastRestartAt = new Date().toISOString();
await this._savePolicies();
const failData = {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
error: restartErr.message,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-failed
* @type {Object}
*/
this.emit('auto-restart-failed', failData);
// Notify
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `❌ Auto-restart attempt ${attemptNum}/${maxRetries} failed for "${serviceId}": ${restartErr.message}`,
...failData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
this.log.error('auto-restart', 'Restart failed', {
serviceId,
attempt: attemptNum,
error: restartErr.message,
});
return { action: 'failed', ...failData };
}
}
/**
* Called when a container recovers to healthy state.
* Resets the retry counter for the associated service.
*
* @param {string} serviceId
* @returns {Promise<void>}
*/
async handleContainerUp(serviceId) {
const policy = this.policies.get(serviceId);
if (!policy) return;
if (policy.currentRetries > 0) {
policy.currentRetries = 0;
policy.cooldownUntil = null;
await this._savePolicies();
this.log.info('auto-restart', 'Retries reset after recovery', { serviceId });
}
}
// ─── Health Event Bridge ─────────────────────────────────────────────
/**
* Internal handler for HealthChecker `status-check` events.
* Detects healthy→unhealthy and unhealthy→healthy transitions for tracked services.
*
* @param {Object} status - HealthChecker status object
* @param {string} status.serviceId
* @param {string} status.status - "up" or "down"
* @private
*/
async _handleStatusCheck(status) {
const { serviceId, status: currentStatus } = status;
if (!serviceId) return;
// Only process services that have a restart policy
if (!this.policies.has(serviceId)) return;
const previousStatus = this._previousHealth.get(serviceId);
this._previousHealth.set(serviceId, currentStatus);
// Transition: healthy → unhealthy
if (previousStatus === 'up' && currentStatus === 'down') {
// Find the containerId from the health checker config or status details
const containerId = this._resolveContainerId(serviceId, status);
if (containerId) {
try {
await this.handleContainerDown(serviceId, containerId);
} catch (err) {
this.logError('auto-restart-health-bridge', err);
}
}
}
// Transition: unhealthy → healthy (recovery)
if (previousStatus === 'down' && currentStatus === 'up') {
try {
await this.handleContainerUp(serviceId);
} catch (err) {
this.logError('auto-restart-health-bridge', err);
}
}
}
/**
* Attempt to find the containerId for a service from various sources.
*
* @param {string} serviceId
* @param {Object} status - The status-check event data
* @returns {string|null}
* @private
*/
_resolveContainerId(serviceId, status) {
// Check if it's in the status details (some health checks embed it)
if (status.details?.containerId) return status.details.containerId;
// Look in the health checker config
const hcService = this.healthChecker?.config?.services?.[serviceId];
if (hcService?.containerId) return hcService.containerId;
// Try to look it up from the services state manager
try {
const servicesStateManager = this.ctx.servicesStateManager;
if (servicesStateManager) {
const readResult = servicesStateManager.read();
if (readResult && typeof readResult.then === 'function') {
// It returns a promise — fire-and-forget lookup
readResult.then(list => {
const found = (list || []).find(s => s.id === serviceId);
return found?.containerId || null;
}).catch(() => null);
} else {
const found = (readResult || []).find(s => s.id === serviceId);
if (found?.containerId) return found.containerId;
}
}
} catch (_) { /* best effort */ }
return null;
}
// ─── Persistence ─────────────────────────────────────────────────────
/**
* Persist current policies to disk.
* @returns {Promise<void>}
* @private
*/
async _savePolicies() {
try {
const obj = {};
for (const [serviceId, policy] of this.policies.entries()) {
obj[serviceId] = { ...policy };
}
await writeJsonFile(this.policiesFile, obj);
} catch (err) {
this.log.error('auto-restart', 'Failed to save policies', { error: err.message });
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
/**
* Send a notification via the notification manager.
*
* @param {string} event - Event type (e.g. 'auto-restart')
* @param {Object} data - Notification payload
* @returns {Promise<Object>}
* @private
*/
async _notify(event, data) {
if (this.notification?.send) {
return this.notification.send(event, data);
}
return { success: false, reason: 'no-notification-manager' };
}
}
module.exports = { AutoRestartManager, DEFAULT_POLICY };
+82 -328
View File
@@ -9,6 +9,15 @@ const { execSync } = require('child_process');
const crypto = require('crypto'); const crypto = require('crypto');
const EventEmitter = require('events'); const EventEmitter = require('events');
// Format bytes to human readable string
function formatBytes(bytes) {
if (bytes === 0 || bytes === undefined || bytes === null) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json'); const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json');
const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json'); const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
@@ -20,14 +29,6 @@ class BackupManager extends EventEmitter {
this.history = this.loadHistory(); this.history = this.loadHistory();
this.scheduledJobs = new Map(); this.scheduledJobs = new Map();
this.running = false; this.running = false;
this.notificationManager = null;
}
/**
* Set the notification manager for sending backup notifications
*/
setNotificationManager(nm) {
this.notificationManager = nm;
} }
/** /**
@@ -83,7 +84,7 @@ class BackupManager extends EventEmitter {
case 'monthly': case 'monthly':
intervalMs = 30 * 24 * 60 * 60 * 1000; intervalMs = 30 * 24 * 60 * 60 * 1000;
break; break;
default: { default:
// Custom interval in minutes // Custom interval in minutes
const minutes = parseInt(backup.schedule, 10); const minutes = parseInt(backup.schedule, 10);
if (!isNaN(minutes) && minutes > 0) { if (!isNaN(minutes) && minutes > 0) {
@@ -92,7 +93,6 @@ class BackupManager extends EventEmitter {
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`); console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
return; return;
} }
}
} }
// Schedule the job // Schedule the job
@@ -184,15 +184,12 @@ class BackupManager extends EventEmitter {
await this.cleanupOldBackups(name, backup.retention); await this.cleanupOldBackups(name, backup.retention);
} }
this.emit('backup-complete', historyEntry); // Enforce storage limit (delete oldest until within maxStorageBytes)
if (backup.maxStorageBytes) {
// Send notification if manager is configured await this.enforceStorageLimit(name, backup.maxStorageBytes);
if (this.notificationManager) {
this.notificationManager.sendBackupComplete(historyEntry).catch(err => {
console.error('[BackupManager] Failed to send backup-complete notification:', err.message);
});
} }
this.emit('backup-complete', historyEntry);
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`); console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
return historyEntry; return historyEntry;
@@ -209,14 +206,7 @@ class BackupManager extends EventEmitter {
this.addToHistory(historyEntry); this.addToHistory(historyEntry);
this.emit('backup-failed', historyEntry); this.emit('backup-failed', historyEntry);
// Send notification if manager is configured
if (this.notificationManager) {
this.notificationManager.sendBackupFailed(historyEntry).catch(err => {
console.error('[BackupManager] Failed to send backup-failed notification:', err.message);
});
}
throw error; throw error;
} }
} }
@@ -566,42 +556,17 @@ class BackupManager extends EventEmitter {
switch (destination.type) { switch (destination.type) {
case 'local': case 'local':
return await this.saveToLocal(data, destination, backupId); 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: default:
throw new Error(`Unsupported destination type: ${destination.type}`); 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 * Save to local filesystem
*/ */
async saveToLocal(data, destination, backupId) { async saveToLocal(data, destination, backupId) {
const backupDir = destination.path || DEFAULT_BACKUP_DIR; const backupDir = destination.path || DEFAULT_BACKUP_DIR;
// Ensure directory exists // Ensure directory exists
if (!fs.existsSync(backupDir)) { if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true }); fs.mkdirSync(backupDir, { recursive: true });
@@ -609,9 +574,9 @@ class BackupManager extends EventEmitter {
const filename = `${backupId}.backup`; const filename = `${backupId}.backup`;
const filepath = path.join(backupDir, filename); const filepath = path.join(backupDir, filename);
fs.writeFileSync(filepath, data); fs.writeFileSync(filepath, data);
return { return {
type: 'local', type: 'local',
path: filepath, path: filepath,
@@ -619,257 +584,6 @@ 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 (_) { /* ignore */ }
}
}
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 (_) { /* ignore */ }
}
}
/**
* 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 (_) { /* ignore */ }
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 (_) { /* ignore */ }
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 (_) { /* ignore */ }
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 (_) { /* ignore */ }
} finally {
try { await client.end(); } catch (_) { /* ignore */ }
}
return;
}
}
/** /**
* Verify backup integrity * Verify backup integrity
*/ */
@@ -904,24 +618,9 @@ class BackupManager extends EventEmitter {
throw new Error(`Backup not found: ${backupId}`); throw new Error(`Backup not found: ${backupId}`);
} }
// Load backup data — try each destination location until one succeeds // Load backup data
const location = backup.locations[0]; // Primary location const location = backup.locations[0]; // Use first location
let data; let data = fs.readFileSync(location.path);
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 (_) { /* ignore */ }
}
if (!recovered) throw loadErr;
}
// Decrypt if needed // Decrypt if needed
if (backup.encrypted && options.encryptionKey) { if (backup.encrypted && options.encryptionKey) {
@@ -1018,6 +717,63 @@ class BackupManager extends EventEmitter {
console.log('[BackupManager] Stats restored'); console.log('[BackupManager] Stats restored');
} }
/**
* Enforce storage limit by deleting oldest backups until total is within limit
*/
async enforceStorageLimit(name, maxBytes) {
const maxStr = formatBytes(maxBytes);
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
const backups = this.history
.filter(b => b.name === name && b.status === 'success')
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
let totalSize = 0;
const locationsMap = {};
for (const backup of backups) {
for (const loc of backup.locations || []) {
if (loc.type === 'local' && loc.path) {
totalSize += loc.size || 0;
locationsMap[backup.id] = locationsMap[backup.id] || [];
locationsMap[backup.id].push(loc.path);
}
}
}
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
if (totalSize <= maxBytes) {
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
return;
}
let freed = 0;
for (const backup of backups) {
if (totalSize <= maxBytes) break;
const paths = locationsMap[backup.id] || [];
for (const path of paths) {
try {
if (fs.existsSync(path)) {
fs.unlinkSync(path);
const sz = backup.size || 0;
totalSize -= sz;
freed += sz;
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
}
} catch (error) {
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
}
}
this.history = this.history.filter(b => b.id !== backup.id);
}
this.saveHistory();
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
}
/** /**
* Cleanup old backups based on retention policy * Cleanup old backups based on retention policy
*/ */
@@ -1032,18 +788,16 @@ class BackupManager extends EventEmitter {
for (const backup of toDelete) { for (const backup of toDelete) {
try { try {
// Delete from all locations (local + cloud) // Delete from all locations
for (const location of backup.locations) { for (const location of backup.locations) {
try { if (location.type === 'local' && fs.existsSync(location.path)) {
await this._deleteFromDestination(location); fs.unlinkSync(location.path);
} catch (delErr) {
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
} }
} }
// Remove from history // Remove from history
this.history = this.history.filter(b => b.id !== backup.id); this.history = this.history.filter(b => b.id !== backup.id);
console.log(`[BackupManager] Deleted old backup: ${backup.id}`); console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
} catch (error) { } catch (error) {
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message); console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
-376
View File
@@ -1,376 +0,0 @@
/**
* Config Drift Detector - Compares services.json with live Docker state
*
* Detects discrepancies between the configured service list and what is
* actually running in Docker, including missing containers, unknown
* containers, port mismatches, state mismatches, and stale records.
*
* @module config-drift-detector
*/
const EventEmitter = require('events');
/**
* @typedef {Object} DriftReport
* @property {string} checkedAt - ISO timestamp of the check
* @property {Object[]} missingContainers - Services with containerId but container absent in Docker
* @property {Object[]} unknownContainers - Running Docker containers with sami.managed label but not in services.json
* @property {Object[]} portMismatch - Service port != container mapped port
* @property {Object[]} stateMismatch - Service expected up but container stopped/absent
* @property {Object[]} staleRecords - Services with containerId pointing to removed containers
* @property {boolean} hasDrift - Whether any drift category is non-empty
*/
/**
* Detects and reports configuration drift between services.json and Docker.
*
* @extends EventEmitter
*
* @fires ConfigDriftDetector#drift-detected
*/
class ConfigDriftDetector extends EventEmitter {
/**
* @param {Object} ctx - Shared application context
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
* @param {Object} ctx.servicesStateManager - StateManager for services.json
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {Function} ctx.logError - Error logging function
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_c, err) => console.error(err));
this.docker = ctx.docker;
this.servicesStateManager = ctx.servicesStateManager;
this.notification = ctx.notification;
/** @type {DriftReport|null} Cached report from last detection */
this.lastReport = null;
/** @type {NodeJS.Timeout|null} Polling timer reference */
this._pollTimer = null;
/** Whether polling is currently active */
this._polling = false;
}
// ─── Detection ───────────────────────────────────────────────────────
/**
* Run a full drift detection and return the report.
*
* Reads services from servicesStateManager and live containers from Docker,
* then compares them across five drift categories.
*
* @returns {Promise<DriftReport>}
*/
async detect() {
const checkedAt = new Date().toISOString();
// Gather configured services
let services = [];
try {
const data = await this.servicesStateManager.read();
services = Array.isArray(data) ? data : (data.services || []);
} catch (err) {
this.log.error('drift', 'Failed to read services', { error: err.message });
}
// Gather live Docker containers
let containers = [];
try {
containers = await this.docker.client.listContainers({ all: true });
} catch (err) {
this.log.error('drift', 'Failed to list containers', { error: err.message });
}
// Build lookup maps
const containerById = new Map(); // containerId (short or long) → container info
const containerByName = new Map(); // container name → container info
for (const c of containers) {
// Store by full ID
containerById.set(c.Id, c);
// Store by short ID (first 12 chars)
if (c.Id && c.Id.length >= 12) {
containerById.set(c.Id.substring(0, 12), c);
}
// Store by name (strip leading /)
for (const name of (c.Names || [])) {
containerByName.set(name.replace(/^\//, ''), c);
}
}
// Build set of service containerIds for reverse lookup
const serviceContainerIds = new Set();
const serviceByContainerId = new Map();
for (const svc of services) {
if (svc.containerId) {
serviceContainerIds.add(svc.containerId);
// Index by both full and short ID
serviceByContainerId.set(svc.containerId, svc);
if (svc.containerId.length >= 12) {
serviceByContainerId.set(svc.containerId.substring(0, 12), svc);
}
}
}
const missingContainers = [];
const portMismatch = [];
const stateMismatch = [];
const staleRecords = [];
for (const svc of services) {
if (!svc.containerId) continue;
// Look up the container
const container = containerById.get(svc.containerId)
|| containerById.get(svc.containerId.substring(0, 12));
if (!container) {
// Container ID referenced but not found in Docker at all
staleRecords.push({
serviceId: svc.id,
name: svc.name,
containerId: svc.containerId,
reason: 'Container not found in Docker',
});
continue;
}
// Missing container — service expects it but it's not running
if (container.State !== 'running') {
missingContainers.push({
serviceId: svc.id,
name: svc.name,
containerId: svc.containerId,
containerState: container.State,
containerStatus: container.Status,
});
// Also a state mismatch if the service is expected to be up
stateMismatch.push({
serviceId: svc.id,
name: svc.name,
expectedState: 'running',
actualState: container.State,
containerId: svc.containerId,
});
}
// Port mismatch detection
if (svc.port && container.State === 'running') {
const actualPorts = this._extractContainerPorts(container);
if (actualPorts.length > 0 && !actualPorts.includes(svc.port)) {
portMismatch.push({
serviceId: svc.id,
name: svc.name,
configuredPort: svc.port,
actualPorts,
containerId: svc.containerId,
});
}
}
}
// Unknown managed containers: Docker containers with sami.managed label
// that are NOT in services.json
const unknownContainers = [];
for (const c of containers) {
const isManaged = c.Labels && c.Labels['sami.managed'] === 'true';
if (!isManaged) continue;
const isInServices = serviceByContainerId.has(c.Id)
|| serviceByContainerId.has(c.Id.substring(0, 12));
if (!isInServices) {
unknownContainers.push({
containerId: c.Id,
name: (c.Names && c.Names[0] || '').replace(/^\//, ''),
image: c.Image,
state: c.State,
status: c.Status,
app: c.Labels?.['sami.app'] || null,
subdomain: c.Labels?.['sami.subdomain'] || null,
});
}
}
const report = {
checkedAt,
missingContainers,
unknownContainers,
portMismatch,
stateMismatch,
staleRecords,
hasDrift: missingContainers.length > 0
|| unknownContainers.length > 0
|| portMismatch.length > 0
|| stateMismatch.length > 0
|| staleRecords.length > 0,
};
// Cache for quick API access
this.lastReport = report;
// Emit and notify if drift detected
if (report.hasDrift) {
/**
* @event ConfigDriftDetector#drift-detected
* @type {DriftReport}
*/
this.emit('drift-detected', report);
try {
await this._sendDriftNotification(report);
} catch (notifErr) {
this.log.error('drift', 'Failed to send drift notification', {
error: notifErr.message,
});
}
}
this.log.info('drift', 'Detection complete', {
hasDrift: report.hasDrift,
missing: report.missingContainers.length,
unknown: report.unknownContainers.length,
portMismatch: report.portMismatch.length,
stateMismatch: report.stateMismatch.length,
stale: report.staleRecords.length,
});
return report;
}
// ─── Auto-fix ────────────────────────────────────────────────────────
/**
* Attempt to auto-fix drift:
* - Remove stale records (services referencing removed containers)
* - Flag unknown containers for review
*
* @returns {Promise<{ staleRemoved: number, unknownFlagged: number }>}
*/
async autoFix() {
const report = await this.detect();
let staleRemoved = 0;
// Remove stale records from services.json
if (report.staleRecords.length > 0) {
const staleIds = new Set(report.staleRecords.map(r => r.serviceId));
await this.servicesStateManager.update(services => {
const before = services.length;
const cleaned = services.filter(s => !staleIds.has(s.id));
staleRemoved = before - cleaned.length;
return cleaned;
});
}
const unknownFlagged = report.unknownContainers.length;
this.log.info('drift', 'Auto-fix applied', { staleRemoved, unknownFlagged });
return { staleRemoved, unknownFlagged };
}
// ─── Polling ─────────────────────────────────────────────────────────
/**
* Start periodic drift detection.
*
* @param {number} [intervalMs=300000] - Polling interval in milliseconds (default 5 min)
*/
startPolling(intervalMs = 300000) {
this.stopPolling();
this._polling = true;
this._pollTimer = setInterval(async () => {
try {
await this.detect();
} catch (err) {
this.logError('drift-poll', err);
}
}, intervalMs);
this.log.info('drift', 'Polling started', { intervalMs });
}
/**
* Stop periodic drift detection.
*/
stopPolling() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
this._polling = false;
this.log.info('drift', 'Polling stopped');
}
/**
* Whether polling is currently active.
* @returns {boolean}
*/
isPolling() {
return this._polling;
}
// ─── Helpers ─────────────────────────────────────────────────────────
/**
* Extract mapped host ports from a Docker container info object.
*
* @param {Object} container - Dockerode container info
* @returns {number[]} Array of host port numbers
* @private
*/
_extractContainerPorts(container) {
const ports = [];
if (!container.Ports) return ports;
for (const p of container.Ports) {
if (p.PublicPort) {
ports.push(p.PublicPort);
}
}
return ports;
}
/**
* Send a notification about detected drift.
*
* @param {DriftReport} report
* @returns {Promise<Object>}
* @private
*/
async _sendDriftNotification(report) {
if (!this.notification?.send) {
return { success: false, reason: 'no-notification-manager' };
}
const parts = [];
if (report.missingContainers.length > 0) {
parts.push(`Missing containers: ${report.missingContainers.map(c => c.name).join(', ')}`);
}
if (report.unknownContainers.length > 0) {
parts.push(`Unknown managed containers: ${report.unknownContainers.map(c => c.name).join(', ')}`);
}
if (report.portMismatch.length > 0) {
parts.push(`Port mismatches: ${report.portMismatch.map(c => c.name).join(', ')}`);
}
if (report.staleRecords.length > 0) {
parts.push(`Stale records: ${report.staleRecords.map(c => c.name).join(', ')}`);
}
return this.notification.send('drift-detected', {
text: `⚠️ Configuration drift detected:\n${parts.join('\n')}`,
report,
});
}
}
module.exports = { ConfigDriftDetector };
+109
View File
@@ -0,0 +1,109 @@
[
{
"id": "router",
"name": "Router UI",
"logo": "/assets/router.png",
"url": "https://router.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "chat",
"name": "Chat",
"logo": "/assets/chat.png",
"url": "https://chat.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "sync",
"name": "Syncthing",
"logo": "/assets/syncthing.png",
"url": "https://sync.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "torrent",
"name": "qBittorrent",
"logo": "/assets/qBittorrent.png",
"url": "https://torrent.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:55.246Z"
},
{
"id": "sonarr",
"name": "Sonarr",
"logo": "/assets/sonarr.png",
"url": "https://sonarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:56.612Z"
},
{
"id": "radarr",
"name": "Radarr",
"logo": "/assets/radarr.png",
"url": "https://radarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:12.359Z"
},
{
"id": "prowlarr",
"name": "Prowlarr",
"logo": "/assets/prowlarr.png",
"url": "https://prowlarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:13.739Z"
},
{
"id": "ca",
"name": "DashCA",
"logo": "/assets/certificate-icon.png",
"containerId": null,
"appTemplate": "dashca",
"tailscaleOnly": false,
"deployedAt": "2026-02-11T11:47:08.383Z",
"url": "https://ca.sami"
},
{
"id": "plex",
"name": "Plex",
"logo": "/assets/plex.png",
"containerId": null,
"appTemplate": "plex",
"tailscaleOnly": false,
"deployedAt": "2026-02-12T02:18:36.067Z",
"url": "https://plex.sami"
},
{
"id": "requests",
"name": "Seerr",
"logo": "/assets/seerr.png",
"url": "https://requests.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "git",
"name": "Gitea",
"logo": "/assets/gitea.png",
"url": "https://git.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "files",
"name": "Sami Files",
"logo": "/assets/sami-files.png",
"url": "https://files.sami",
"ip": "localhost",
"tailscaleOnly": false,
"containerId": null,
"appTemplate": "sami-files",
"deployedAt": "2026-06-19T00:00:00.000Z"
}
]
-605
View File
@@ -1,605 +0,0 @@
/**
* Dependency Manager - Service dependency tracking with ordered restart chains
*
* Manages directed acyclic graph (DAG) of service dependencies. Services can
* declare which other services they depend on, and this manager provides:
* - Full dependency graph inspection
* - Topological ordering for safe restart chains
* - Circular dependency detection
* - Health-aware restart with per-service polling
*
* Dependencies are stored directly on service objects in services.json:
* { id, name, ..., dependsOn: ['service-id-1', 'service-id-2'] }
*
* @module dependency-manager
*/
const EventEmitter = require('events');
/** Maximum seconds to wait for a single container to become healthy after restart */
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
/** Interval between container health polls */
const HEALTH_CHECK_INTERVAL_MS = 1_000;
/**
* @typedef {Object} ServiceNode
* @property {string} serviceId
* @property {string} name
* @property {string|null} containerId
*/
/**
* @typedef {Object} DependencyEdge
* @property {string} from - The service that depends
* @property {string} to - The service being depended upon
*/
/**
* @typedef {Object} DependencyGraph
* @property {ServiceNode[]} nodes
* @property {DependencyEdge[]} edges
*/
/**
* @typedef {Object} DependencyStatusEntry
* @property {string} serviceId
* @property {string} name
* @property {boolean} isUp
* @property {string} [error]
*/
/**
* DependencyManager — tracks service dependencies and orchestrates ordered restarts.
*
* Events emitted:
* - `dependency-restart-start` ({ serviceId, chain: string[] })
* - `dependency-restart-progress` ({ serviceId, currentServiceId, index, total })
* - `dependency-restart-complete` ({ serviceId, chain: string[], results: Array })
* - `dependency-restart-failed` ({ serviceId, failedServiceId, error, chain: string[] })
*
* @extends EventEmitter
*/
class DependencyManager extends EventEmitter {
/**
* @param {Object} ctx - Application context
* @param {Object} ctx.servicesStateManager - StateManager for services.json
* @param {Object} ctx.docker - Docker context ({ client: Dockerode })
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
*/
constructor(ctx) {
super();
/** @private */
this.ctx = ctx;
/** @private */
this._servicesStateManager = ctx.servicesStateManager;
/** @private */
this._docker = ctx.docker;
/** @private */
this._notification = ctx.notification;
/** @private */
this._log = ctx.log || console;
}
// ---------------------------------------------------------------------------
// Core helpers
// ---------------------------------------------------------------------------
/**
* Load all services from the state manager.
* @private
* @returns {Promise<Object[]>}
*/
async _loadServices() {
const data = await this._servicesStateManager.read();
return Array.isArray(data) ? data : (data.services || []);
}
/**
* Find a single service by ID.
* @private
* @param {string} serviceId
* @returns {Promise<Object|null>}
*/
async _findService(serviceId) {
const services = await this._loadServices();
return services.find(s => s.id === serviceId) || null;
}
// ---------------------------------------------------------------------------
// Graph queries
// ---------------------------------------------------------------------------
/**
* Return the full dependency graph for visualisation.
*
* @returns {Promise<DependencyGraph>}
*/
async getDependencyGraph() {
const services = await this._loadServices();
const nodes = services.map(s => ({
serviceId: s.id,
name: s.name,
containerId: s.containerId || null,
}));
const edges = [];
for (const service of services) {
const deps = service.dependsOn || [];
for (const depId of deps) {
edges.push({ from: service.id, to: depId });
}
}
return { nodes, edges };
}
/**
* Return the services that depend on the given service (reverse deps).
*
* @param {string} serviceId
* @returns {Promise<Object[]>} Services whose `dependsOn` includes `serviceId`.
*/
async getDependents(serviceId) {
const services = await this._loadServices();
return services.filter(s => (s.dependsOn || []).includes(serviceId));
}
/**
* Return the direct dependencies for a service.
*
* @param {string} serviceId
* @returns {Promise<Object[]>} Services that `serviceId` depends on.
*/
async getDependencies(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) return [];
const depIds = service.dependsOn || [];
return services.filter(s => depIds.includes(s.id));
}
// ---------------------------------------------------------------------------
// Topological sort
// ---------------------------------------------------------------------------
/**
* Build an adjacency list for the current dependency graph.
* Edge direction: service → its dependencies (i.e. what it depends on).
*
* @private
* @param {Object[]} services
* @returns {Map<string, string[]>}
*/
_buildAdjacencyList(services) {
const adj = new Map();
for (const service of services) {
adj.set(service.id, (service.dependsOn || []).slice());
}
return adj;
}
/**
* DFS-based topological sort with cycle detection (white/gray/black coloring).
*
* Returns services in restart order: dependencies first, dependents last.
* The target service is included at the end.
*
* @private
* @param {string} serviceId - Target service (will be last in the result).
* @param {Object[]} services - All services.
* @param {Map<string, string[]>} adj - Adjacency list (service → deps).
* @returns {string[]} Ordered service IDs for restart.
* @throws {Error} If a circular dependency is detected.
*/
_topologicalSort(serviceId, services, adj) {
// Collect only the reachable sub-graph from serviceId
const visited = new Set();
const reachable = new Set();
const collectReachable = (id) => {
if (reachable.has(id)) return;
reachable.add(id);
for (const dep of (adj.get(id) || [])) {
collectReachable(dep);
}
};
collectReachable(serviceId);
// DFS topological sort on the reachable sub-graph
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map();
for (const id of reachable) color.set(id, WHITE);
const result = [];
const dfs = (id) => {
if (color.get(id) === BLACK) return;
if (color.get(id) === GRAY) {
throw new Error(`Circular dependency detected involving service "${id}"`);
}
color.set(id, GRAY);
for (const dep of (adj.get(id) || [])) {
dfs(dep);
}
color.set(id, BLACK);
result.push(id);
};
// Visit the target last so it ends up at the end of the result
// Actually, we want deps *first* then the target.
// The DFS naturally puts deps before dependents, so starting from
// serviceId will place it last (which is correct for restart order).
dfs(serviceId);
return result;
}
/**
* Get the topologically ordered restart chain for a service.
*
* The returned array lists all services that must be restarted,
* starting with leaf dependencies and ending with the target service.
*
* @param {string} serviceId - The service to build the chain for.
* @returns {Promise<string[]>} Ordered service IDs.
* @throws {Error} If `serviceId` doesn't exist or a circular dependency is found.
*/
async getOrderedRestartChain(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) {
throw new Error(`Service "${serviceId}" not found`);
}
const adj = this._buildAdjacencyList(services);
return this._topologicalSort(serviceId, services, adj);
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/**
* Validate a proposed set of dependencies for a service.
*
* Checks:
* - All referenced service IDs exist.
* - Adding these dependencies would not create a circular dependency.
* - A service cannot depend on itself.
*
* @param {string} serviceId - The service to set dependencies on.
* @param {string[]} dependsOn - Proposed dependency IDs.
* @returns {Promise<{ valid: boolean, errors: string[] }>}
*/
async validateDependencies(serviceId, dependsOn) {
const errors = [];
if (!Array.isArray(dependsOn)) {
return { valid: false, errors: ['dependsOn must be an array'] };
}
const services = await this._loadServices();
const allIds = new Set(services.map(s => s.id));
// Service must exist
if (!allIds.has(serviceId)) {
return { valid: false, errors: [`Service "${serviceId}" not found`] };
}
// Self-dependency
if (dependsOn.includes(serviceId)) {
errors.push(`Service "${serviceId}" cannot depend on itself`);
}
// Existence check
for (const depId of dependsOn) {
if (!allIds.has(depId)) {
errors.push(`Dependency service "${depId}" does not exist`);
}
}
if (errors.length > 0) {
return { valid: false, errors };
}
// Circular dependency check: temporarily set the proposed dependsOn
// and attempt a topological sort.
const tempServices = services.map(s => {
if (s.id === serviceId) {
return { ...s, dependsOn: dependsOn.slice() };
}
return { ...s };
});
const adj = this._buildAdjacencyList(tempServices);
// Check every node for cycles with the new edges
try {
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map();
for (const s of tempServices) color.set(s.id, WHITE);
const dfs = (id) => {
if (color.get(id) === BLACK) return;
if (color.get(id) === GRAY) {
throw new Error(`Circular dependency detected involving service "${id}"`);
}
color.set(id, GRAY);
for (const dep of (adj.get(id) || [])) {
dfs(dep);
}
color.set(id, BLACK);
};
for (const s of tempServices) {
if (color.get(s.id) === WHITE) {
dfs(s.id);
}
}
} catch (err) {
errors.push(err.message);
}
return { valid: errors.length === 0, errors };
}
// ---------------------------------------------------------------------------
// Health status
// ---------------------------------------------------------------------------
/**
* Get the current container status for a service and all its transitive dependencies.
*
* @param {string} serviceId
* @returns {Promise<DependencyStatusEntry[]>}
* @throws {Error} If `serviceId` doesn't exist.
*/
async getDependencyStatus(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) {
throw new Error(`Service "${serviceId}" not found`);
}
// Collect all transitive dependencies via BFS
const serviceMap = new Map(services.map(s => [s.id, s]));
const visited = new Set();
const queue = [serviceId];
const allRelated = [];
while (queue.length > 0) {
const currentId = queue.shift();
if (visited.has(currentId)) continue;
visited.add(currentId);
const svc = serviceMap.get(currentId);
if (!svc) continue;
allRelated.push(svc);
for (const depId of (svc.dependsOn || [])) {
if (!visited.has(depId)) {
queue.push(depId);
}
}
}
// Query container status for each
const results = [];
for (const svc of allRelated) {
const entry = {
serviceId: svc.id,
name: svc.name,
isUp: false,
};
if (!svc.containerId) {
entry.error = 'No container associated with this service';
results.push(entry);
continue;
}
try {
const container = this._docker.client.getContainer(svc.containerId);
const info = await container.inspect();
entry.isUp = info.State?.Running === true;
} catch (err) {
entry.error = err.message || 'Unable to inspect container';
}
results.push(entry);
}
return results;
}
// ---------------------------------------------------------------------------
// Restart with dependencies
// ---------------------------------------------------------------------------
/**
* Wait for a container to report as running after a restart.
*
* @private
* @param {string} containerId
* @param {number} [timeoutMs=30000]
* @returns {Promise<boolean>} `true` if healthy, `false` if timed out.
*/
async _waitForContainerHealthy(containerId, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const container = this._docker.client.getContainer(containerId);
const info = await container.inspect();
if (info.State?.Running === true) {
return true;
}
} catch {
// Container might not be inspectable during restart — keep polling
}
await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
}
return false;
}
/**
* Restart a service and all its dependencies in topological order.
*
* Emits progress events and sends a notification on completion/failure.
* This method is designed to be called from the route handler and
* **does not throw** — errors are reported via events and notifications.
*
* @param {string} serviceId - Target service to restart (with deps).
* @returns {Promise<{ success: boolean, chain: string[], results: Array }>}
*/
async restartWithDependencies(serviceId) {
const service = await this._findService(serviceId);
if (!service) {
const err = new Error(`Service "${serviceId}" not found`);
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: serviceId,
error: err.message,
chain: [],
});
throw err;
}
let chain;
try {
chain = await this.getOrderedRestartChain(serviceId);
} catch (err) {
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: serviceId,
error: err.message,
chain: [],
});
throw err;
}
const services = await this._loadServices();
const serviceMap = new Map(services.map(s => [s.id, s]));
this._log.info('dependency', 'Starting dependency restart chain', {
serviceId,
chain,
});
this.emit('dependency-restart-start', { serviceId, chain });
const results = [];
const total = chain.length;
for (let i = 0; i < total; i++) {
const currentId = chain[i];
const svc = serviceMap.get(currentId);
this.emit('dependency-restart-progress', {
serviceId,
currentServiceId: currentId,
index: i,
total,
});
if (!svc || !svc.containerId) {
const msg = !svc
? `Service "${currentId}" not found in state`
: `Service "${currentId}" has no container — skipping restart`;
this._log.warn('dependency', msg);
results.push({ serviceId: currentId, restarted: false, skipped: true, reason: msg });
continue;
}
try {
const container = this._docker.client.getContainer(svc.containerId);
this._log.info('dependency', `Restarting container for service "${currentId}"`, {
containerId: svc.containerId,
});
await container.restart();
// Wait for it to come back up
const healthy = await this._waitForContainerHealthy(svc.containerId);
if (!healthy) {
const msg = `Container for service "${currentId}" did not become healthy within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
this._log.warn('dependency', msg);
results.push({ serviceId: currentId, restarted: true, healthy: false, error: msg });
// Abort chain — dependency didn't come back
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: currentId,
error: msg,
chain,
});
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
return { success: false, chain, results };
}
this._log.info('dependency', `Service "${currentId}" is healthy after restart`);
results.push({ serviceId: currentId, restarted: true, healthy: true });
} catch (err) {
const msg = err.message || 'Unknown error during restart';
this._log.error('dependency', `Failed to restart service "${currentId}"`, {
error: msg,
});
results.push({ serviceId: currentId, restarted: false, error: msg });
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: currentId,
error: msg,
chain,
});
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
return { success: false, chain, results };
}
}
this.emit('dependency-restart-complete', { serviceId, chain, results });
await this._notifyRestartResult(serviceId, true, chain, results);
return { success: true, chain, results };
}
/**
* Send a notification about the restart result.
*
* @private
* @param {string} serviceId
* @param {boolean} success
* @param {string[]} chain
* @param {Array} results
* @param {string} [failedServiceId]
*/
async _notifyRestartResult(serviceId, success, chain, results, failedServiceId) {
if (!this._notification) return;
try {
if (success) {
await this._notification.send('dependency-restart-complete', {
text: `✅ Dependency restart chain completed for "${serviceId}". Restarted: ${chain.join(' → ')}`,
serviceId,
chain,
results,
});
} else {
await this._notification.send('dependency-restart-failed', {
text: `❌ Dependency restart chain failed for "${serviceId}" at "${failedServiceId}". Chain: ${chain.join(' → ')}`,
serviceId,
failedServiceId,
chain,
results,
});
}
} catch (err) {
this._log.error('dependency', 'Failed to send restart notification', {
error: err.message,
});
}
}
}
module.exports = DependencyManager;
-273
View File
@@ -1,273 +0,0 @@
/**
* DNS Propagation Checker
* Verifies DNS record propagation by querying multiple resolvers.
* Runs as background jobs with configurable timeout and interval.
*
* @module dns-propagation
*/
const dns = require('dns').promises;
const EventEmitter = require('events');
/** Default verification options */
const DEFAULT_OPTIONS = {
timeout: 300000, // 5 minutes
interval: 10000, // 10 seconds
resolvers: ['1.1.1.1', '8.8.8.8', '9.9.9.9']
};
/** Maximum age for stored verification results (1 hour) */
const MAX_RESULT_AGE_MS = 3600000;
class DNSPropagationChecker extends EventEmitter {
/**
* Create a DNSPropagationChecker instance.
* @param {Object} ctx - Shared application context
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
/** @type {Map<string, Object>} domain → verification status */
this.verifications = new Map();
}
/**
* Verify that a DNS record has propagated by querying multiple resolvers.
* Retries every `interval` ms until `timeout` is reached.
*
* @param {string} domain - The domain to check (e.g., 'test.sami')
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @param {number} [options.timeout=300000] - Maximum time to wait (ms)
* @param {number} [options.interval=10000] - Time between retries (ms)
* @param {string[]} [options.resolvers] - DNS resolvers to query
* @returns {Promise<Object>} Verification result
*/
async verifyRecord(domain, expectedIp, options = {}) {
const startTime = Date.now();
const {
timeout = DEFAULT_OPTIONS.timeout,
interval = DEFAULT_OPTIONS.interval,
resolvers = DEFAULT_OPTIONS.resolvers
} = options;
const allResults = [];
let propagated = false;
while (Date.now() - startTime < timeout) {
const roundResults = [];
for (const resolver of resolvers) {
const checkStart = Date.now();
try {
// Use dns.resolve4 with a custom resolver
const resolverInstance = new dns.Resolver();
resolverInstance.setServers([resolver]);
resolverInstance.setTimeout(5000);
const addresses = await resolverInstance.resolve4(domain);
const matched = addresses.includes(expectedIp);
const result = {
resolver,
ips: addresses,
matched,
checkedAt: new Date().toISOString(),
responseTime: Date.now() - checkStart
};
roundResults.push(result);
if (matched) {
propagated = true;
}
} catch (err) {
roundResults.push({
resolver,
ips: [],
matched: false,
checkedAt: new Date().toISOString(),
error: err.code || err.message,
responseTime: Date.now() - checkStart
});
}
}
allResults.push(...roundResults);
// Emit progress event
this.emit('propagation-check', {
domain,
expectedIp,
roundResults,
elapsed: Date.now() - startTime,
propagated
});
if (propagated) {
break;
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, interval));
}
const totalTime = Date.now() - startTime;
return {
domain,
expectedIp,
propagated,
results: allResults,
totalTime,
checkedAt: new Date().toISOString()
};
}
/**
* Start a background DNS propagation verification.
* Does not block — returns immediately with the job reference.
*
* @param {string} domain - The domain to verify
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @returns {Object} Job status object
*/
startVerification(domain, expectedIp, options = {}) {
// If there's already a running verification for this domain, return it
const existing = this.verifications.get(domain);
if (existing && existing.status === 'running') {
return existing;
}
const job = {
domain,
expectedIp,
status: 'running',
startedAt: new Date().toISOString(),
progress: [],
result: null
};
this.verifications.set(domain, job);
// Run verification in background (non-blocking)
this.verifyRecord(domain, expectedIp, options)
.then(result => {
job.status = 'completed';
job.result = result;
job.completedAt = new Date().toISOString();
if (result.propagated) {
this.emit('propagation-complete', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `✅ DNS record for ${domain} propagated successfully to ${expectedIp}`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'success').catch(err => {
this.log.error('dns-propagation', 'Failed to send propagation notification', {
error: err.message
});
});
}
} else {
this.emit('propagation-timeout', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `⏱️ DNS propagation timeout for ${domain} — expected ${expectedIp} not found after ${Math.round(result.totalTime / 1000)}s`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'warning').catch(err => {
this.log.error('dns-propagation', 'Failed to send timeout notification', {
error: err.message
});
});
}
}
})
.catch(err => {
job.status = 'error';
job.error = err.message;
job.completedAt = new Date().toISOString();
this.log.error('dns-propagation', `Verification failed for ${domain}`, {
error: err.message
});
});
return job;
}
/**
* Get the current verification status for a domain.
*
* @param {string} domain - The domain to look up
* @returns {Object|null} Verification status or null if not found
*/
getVerificationStatus(domain) {
const job = this.verifications.get(domain);
if (!job) return null;
return {
domain: job.domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
result: job.result || null,
error: job.error || null
};
}
/**
* Get all recent verifications.
*
* @returns {Object[]} Array of verification statuses
*/
getAllVerifications() {
const results = [];
for (const [domain, job] of this.verifications.entries()) {
results.push({
domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
propagated: job.result?.propagated || null,
totalTime: job.result?.totalTime || null,
error: job.error || null
});
}
return results;
}
/**
* Remove verifications older than 1 hour.
*/
cleanup() {
const now = Date.now();
for (const [domain, job] of this.verifications.entries()) {
const completedAt = job.completedAt ? new Date(job.completedAt).getTime() : null;
const startedAt = new Date(job.startedAt).getTime();
// Clean up completed/error jobs older than 1 hour
// Also clean up stale running jobs that started over 2 hours ago
const age = completedAt ? (now - completedAt) : (now - startedAt);
const maxAge = job.status === 'running' ? MAX_RESULT_AGE_MS * 2 : MAX_RESULT_AGE_MS;
if (age > maxAge) {
this.verifications.delete(domain);
}
}
}
}
module.exports = DNSPropagationChecker;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.9.0", "version": "1.6.0",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
-164
View File
@@ -1,164 +0,0 @@
/**
* Auto-Restart Policy Routes
*
* CRUD endpoints for per-container auto-restart policies.
* Also provides a dry-run test endpoint.
*
* @module routes/auto-restart
*/
const express = require('express');
const { success } = require('../response-helpers');
const { ValidationError, NotFoundError } = require('../errors');
/**
* Auto-restart route factory
*
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.autoRestartManager - AutoRestartManager instance
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @returns {express.Router}
*/
module.exports = function ({ autoRestartManager, asyncHandler, logError }) {
const router = express.Router();
/**
* GET /auto-restart/policies
* List all configured auto-restart policies.
*/
router.get('/policies', asyncHandler(async (_req, res) => {
const policies = autoRestartManager.listPolicies();
success(res, { policies });
}, 'auto-restart-list'));
/**
* GET /auto-restart/policies/:serviceId
* Get the restart policy for a single service.
*/
router.get('/policies/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const policy = autoRestartManager.getPolicy(serviceId);
if (!policy) {
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
}
success(res, { policy });
}, 'auto-restart-get'));
/**
* POST /auto-restart/policies/:serviceId
* Create or update a restart policy.
*
* Body: { enabled, maxRetries, retryIntervalMs, windowMinutes }
*/
router.post('/policies/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const { enabled, maxRetries, retryIntervalMs, windowMinutes } = req.body;
// Validate inputs
if (enabled !== undefined && typeof enabled !== 'boolean') {
throw new ValidationError('enabled must be a boolean');
}
if (maxRetries !== undefined) {
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 100) {
throw new ValidationError('maxRetries must be an integer between 0 and 100');
}
}
if (retryIntervalMs !== undefined) {
if (!Number.isInteger(retryIntervalMs) || retryIntervalMs < 0 || retryIntervalMs > 3600000) {
throw new ValidationError('retryIntervalMs must be an integer between 0 and 3600000');
}
}
if (windowMinutes !== undefined) {
if (!Number.isInteger(windowMinutes) || windowMinutes < 0 || windowMinutes > 1440) {
throw new ValidationError('windowMinutes must be an integer between 0 and 1440');
}
}
const policy = await autoRestartManager.setPolicy(serviceId, {
...(enabled !== undefined && { enabled }),
...(maxRetries !== undefined && { maxRetries }),
...(retryIntervalMs !== undefined && { retryIntervalMs }),
...(windowMinutes !== undefined && { windowMinutes }),
});
success(res, { policy, message: `Policy ${serviceId} saved` });
}, 'auto-restart-set'));
/**
* DELETE /auto-restart/policies/:serviceId
* Remove a restart policy.
*/
router.delete('/policies/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const removed = await autoRestartManager.removePolicy(serviceId);
if (!removed) {
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
}
success(res, { message: `Policy for "${serviceId}" removed` });
}, 'auto-restart-delete'));
/**
* POST /auto-restart/policies/:serviceId/test
* Dry-run: simulate a restart attempt without actually restarting.
* Returns what *would* happen given the current policy state.
*/
router.post('/policies/:serviceId/test', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const policy = autoRestartManager.getPolicy(serviceId);
if (!policy) {
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
}
const now = Date.now();
const inCooldown = policy.cooldownUntil && now < policy.cooldownUntil;
const wouldRetry = !inCooldown && policy.currentRetries < policy.maxRetries;
const nextAttempt = policy.currentRetries + 1;
success(res, {
dryRun: true,
serviceId,
policy: {
enabled: policy.enabled,
currentRetries: policy.currentRetries,
maxRetries: policy.maxRetries,
cooldownUntil: policy.cooldownUntil,
inCooldown,
},
wouldRestart: policy.enabled && wouldRetry,
wouldMaxOut: !wouldRetry && !inCooldown,
nextAttempt: wouldRetry ? nextAttempt : null,
message: !policy.enabled
? 'Policy is disabled — no restart would occur'
: inCooldown
? `In cooldown until ${new Date(policy.cooldownUntil).toISOString()} — would skip`
: wouldRetry
? `Would attempt restart ${nextAttempt}/${policy.maxRetries}`
: `Max retries (${policy.maxRetries}) already reached — would enter cooldown`,
});
}, 'auto-restart-test'));
return router;
};
-92
View File
@@ -1,92 +0,0 @@
/**
* Config Drift Detection Routes
*
* API endpoints for running drift detection, reading cached reports,
* auto-fixing drift, and controlling periodic polling.
*
* @module routes/config-drift
*/
const express = require('express');
const { success } = require('../response-helpers');
const { ValidationError, NotFoundError } = require('../errors');
/**
* Config-drift route factory
*
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.driftDetector - ConfigDriftDetector instance
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @returns {express.Router}
*/
module.exports = function ({ driftDetector, asyncHandler, logError }) {
const router = express.Router();
/**
* GET /config-drift/report
* Run a fresh drift detection and return the full report.
*/
router.get('/report', asyncHandler(async (_req, res) => {
const report = await driftDetector.detect();
success(res, { report });
}, 'drift-report'));
/**
* GET /config-drift/last
* Return the last cached drift report (no re-detection).
*/
router.get('/last', asyncHandler(async (_req, res) => {
if (!driftDetector.lastReport) {
throw new NotFoundError('No cached drift report — run detection first');
}
success(res, { report: driftDetector.lastReport });
}, 'drift-last'));
/**
* POST /config-drift/fix
* Auto-fix detected drift: remove stale records, flag unknown containers.
*/
router.post('/fix', asyncHandler(async (_req, res) => {
const result = await driftDetector.autoFix();
success(res, {
message: 'Auto-fix applied',
staleRemoved: result.staleRemoved,
unknownFlagged: result.unknownFlagged,
});
}, 'drift-fix'));
/**
* POST /config-drift/polling
* Enable or disable periodic drift detection polling.
*
* Body: { enabled: boolean, intervalMs?: number }
*/
router.post('/polling', asyncHandler(async (req, res) => {
const { enabled, intervalMs } = req.body;
if (typeof enabled !== 'boolean') {
throw new ValidationError('enabled must be a boolean');
}
if (intervalMs !== undefined) {
if (!Number.isInteger(intervalMs) || intervalMs < 10000 || intervalMs > 86400000) {
throw new ValidationError('intervalMs must be an integer between 10000 and 86400000 (10s – 24h)');
}
}
if (enabled) {
driftDetector.startPolling(intervalMs || 300000);
success(res, {
message: 'Drift polling enabled',
intervalMs: intervalMs || 300000,
});
} else {
driftDetector.stopPolling();
success(res, { message: 'Drift polling disabled' });
}
}, 'drift-polling'));
return router;
};
-235
View File
@@ -1,235 +0,0 @@
/**
* Dependencies Route — REST API for service dependency tracking
*
* Endpoints:
* GET /dependencies/graph Full dependency graph
* GET /dependencies/validate Validate a proposed dep chain
* GET /dependencies/:serviceId Direct deps for one service
* GET /dependencies/:serviceId/chain Ordered restart chain
* GET /dependencies/:serviceId/status Dependency health status
* POST /dependencies/:serviceId Set dependencies
* DELETE /dependencies/:serviceId Remove all dependencies
* POST /dependencies/:serviceId/restart Restart with dependency chain
*
* @module routes/dependencies
*/
const express = require('express');
const { success, error: errorResponse } = require('../response-helpers');
const { NotFoundError, ValidationError } = require('../errors');
/**
* Dependencies route factory
*
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.dependencyManager - DependencyManager instance
* @param {Object} deps.servicesStateManager - State manager for services.json
* @param {Object} deps.docker - Docker client wrapper
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @param {Function} deps.resyncHealthChecker - Health checker resync function
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({
dependencyManager,
servicesStateManager,
docker,
asyncHandler,
logError,
resyncHealthChecker,
log,
}) {
const router = express.Router();
// -------------------------------------------------------------------------
// GET /dependencies/graph — Full dependency graph
// -------------------------------------------------------------------------
router.get('/graph', asyncHandler(async (req, res) => {
const graph = await dependencyManager.getDependencyGraph();
success(res, { graph });
}, 'dep-graph'));
// -------------------------------------------------------------------------
// GET /dependencies/validate — Validate a proposed dep chain (query params)
// -------------------------------------------------------------------------
router.get('/validate', asyncHandler(async (req, res) => {
const { serviceId, dependsOn } = req.query;
if (!serviceId) {
throw new ValidationError('serviceId query parameter is required');
}
// dependsOn may be a comma-separated string or already an array
let parsed;
if (Array.isArray(dependsOn)) {
parsed = dependsOn;
} else if (typeof dependsOn === 'string' && dependsOn.length > 0) {
parsed = dependsOn.split(',').map(s => s.trim()).filter(Boolean);
} else {
parsed = [];
}
const result = await dependencyManager.validateDependencies(serviceId, parsed);
success(res, result);
}, 'dep-validate'));
// -------------------------------------------------------------------------
// GET /dependencies/:serviceId — Direct deps for one service
// -------------------------------------------------------------------------
router.get('/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const dependencies = await dependencyManager.getDependencies(serviceId);
const dependents = await dependencyManager.getDependents(serviceId);
// Read the service's current dependsOn array
const services = await servicesStateManager.read();
const allServices = Array.isArray(services) ? services : (services.services || []);
const service = allServices.find(s => s.id === serviceId);
if (!service) {
throw new NotFoundError(`Service "${serviceId}"`);
}
success(res, {
serviceId,
dependsOn: service.dependsOn || [],
dependencies,
dependents: dependents.map(d => ({ id: d.id, name: d.name })),
});
}, 'dep-get'));
// -------------------------------------------------------------------------
// GET /dependencies/:serviceId/chain — Ordered restart chain
// -------------------------------------------------------------------------
router.get('/:serviceId/chain', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const chain = await dependencyManager.getOrderedRestartChain(serviceId);
success(res, { serviceId, chain });
}, 'dep-chain'));
// -------------------------------------------------------------------------
// GET /dependencies/:serviceId/status — Dependency health status
// -------------------------------------------------------------------------
router.get('/:serviceId/status', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const statuses = await dependencyManager.getDependencyStatus(serviceId);
success(res, { serviceId, statuses });
}, 'dep-status'));
// -------------------------------------------------------------------------
// POST /dependencies/:serviceId — Set dependencies
// -------------------------------------------------------------------------
router.post('/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const { dependsOn } = req.body;
if (!Array.isArray(dependsOn)) {
throw new ValidationError('Request body must include dependsOn as an array of service IDs');
}
// Validate first
const validation = await dependencyManager.validateDependencies(serviceId, dependsOn);
if (!validation.valid) {
return errorResponse(res, validation.errors.join('; '), 400);
}
// Update the service
let found = false;
await servicesStateManager.update(services => {
const arr = Array.isArray(services) ? services : [];
return arr.map(s => {
if (s.id === serviceId) {
found = true;
return { ...s, dependsOn: dependsOn.slice() };
}
return s;
});
});
if (!found) {
throw new NotFoundError(`Service "${serviceId}"`);
}
log.info('dependency', 'Dependencies updated', { serviceId, dependsOn });
success(res, {
message: `Dependencies updated for "${serviceId}"`,
serviceId,
dependsOn,
});
}, 'dep-set'));
// -------------------------------------------------------------------------
// DELETE /dependencies/:serviceId — Remove all dependencies for a service
// -------------------------------------------------------------------------
router.delete('/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
let found = false;
await servicesStateManager.update(services => {
const arr = Array.isArray(services) ? services : [];
return arr.map(s => {
if (s.id === serviceId) {
found = true;
const updated = { ...s };
delete updated.dependsOn;
return updated;
}
return s;
});
});
if (!found) {
throw new NotFoundError(`Service "${serviceId}"`);
}
log.info('dependency', 'Dependencies removed', { serviceId });
success(res, {
message: `All dependencies removed for "${serviceId}"`,
serviceId,
});
}, 'dep-delete'));
// -------------------------------------------------------------------------
// POST /dependencies/:serviceId/restart — Restart with dependency chain
// -------------------------------------------------------------------------
router.post('/:serviceId/restart', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
// Verify the service exists
const services = await servicesStateManager.read();
const allServices = Array.isArray(services) ? services : (services.services || []);
if (!allServices.find(s => s.id === serviceId)) {
throw new NotFoundError(`Service "${serviceId}"`);
}
// Get the chain first for the response (before async restart begins)
let chain;
try {
chain = await dependencyManager.getOrderedRestartChain(serviceId);
} catch (err) {
return errorResponse(res, err.message, 400);
}
// Respond immediately with the chain order
success(res, {
message: `Dependency restart initiated for "${serviceId}"`,
serviceId,
chain,
});
// Run the restart chain asynchronously so the client doesn't block
dependencyManager.restartWithDependencies(serviceId).catch(err => {
if (log) {
log.error('dependency', 'Async dependency restart failed', {
serviceId,
error: err.message,
});
}
});
}, 'dep-restart'));
return router;
};
+1 -73
View File
@@ -26,8 +26,7 @@ module.exports = function({
log, log,
safeErrorMessage, safeErrorMessage,
fetchT, fetchT,
credentialManager, credentialManager
dnsPropagationChecker
}) { }) {
const router = express.Router(); const router = express.Router();
@@ -140,14 +139,6 @@ module.exports = function({
}); });
if (result.status === 'ok') { if (result.status === 'ok') {
// Start DNS propagation verification in background
if (dnsPropagationChecker && ip) {
const fullDomain = domain;
dnsPropagationChecker.startVerification(fullDomain, ip).catch(err => {
log('DNS propagation check start failed:', err.message);
});
}
success(res, { message: `DNS record ${domain} -> ${ip} created` }); success(res, { message: `DNS record ${domain} -> ${ip} created` });
} else { } else {
// Error handled by middleware // Error handled by middleware
@@ -650,68 +641,5 @@ module.exports = function({
} }
}, 'dns-update')); }, 'dns-update'));
// ===== DNS PROPAGATION =====
// GET /propagation — Get all recent DNS propagation checks
router.get('/propagation', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return success(res, { verifications: [], message: 'DNS propagation checker not available' });
}
// Cleanup old entries
dnsPropagationChecker.cleanup();
const verifications = dnsPropagationChecker.getAllVerifications();
success(res, { verifications });
}, 'dns-propagation-all'));
// POST /propagation/verify — Manually trigger DNS propagation verification
router.post('/propagation/verify', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return errorResponse(res, 'DNS propagation checker not available', 503);
}
const { domain, expectedIp } = req.body;
if (!domain || !expectedIp) {
throw new ValidationError('domain and expectedIp are required');
}
// Validate domain format
if (!REGEX.DOMAIN.test(domain)) {
throw new ValidationError('[DC-301] Invalid domain format');
}
// Validate IP address
const validatorLib = require('validator');
if (!validatorLib.isIP(expectedIp)) {
throw new ValidationError('[DC-210] Invalid IP address');
}
const job = dnsPropagationChecker.startVerification(domain, expectedIp);
success(res, {
message: 'DNS propagation verification started',
domain,
expectedIp,
status: job.status
});
}, 'dns-propagation-verify'));
// GET /propagation/:domain — Get propagation status for a specific domain
router.get('/propagation/:domain', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return success(res, { verification: null, message: 'DNS propagation checker not available' });
}
const { domain } = req.params;
const status = dnsPropagationChecker.getVerificationStatus(domain);
if (!status) {
throw new NotFoundError(`No propagation check found for domain: ${domain}`);
}
success(res, { verification: status });
}, 'dns-propagation-domain'));
return router; return router;
}; };
+1 -44
View File
@@ -8,10 +8,9 @@ const express = require('express');
* @param {Object} deps.healthChecker - Health checker * @param {Object} deps.healthChecker - Health checker
* @param {Object} deps.updateManager - Update manager * @param {Object} deps.updateManager - Update manager
* @param {Function} deps.logError - Error logging function * @param {Function} deps.logError - Error logging function
* @param {Object} deps.dependencyManager - Dependency manager for restart chain events
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, dependencyManager, autoRestartManager, driftDetector, sslMonitor, dnsPropagationChecker }) { module.exports = function({ resourceMonitor, healthChecker, updateManager, logError }) {
const router = express.Router(); const router = express.Router();
const clients = new Set(); const clients = new Set();
@@ -75,48 +74,6 @@ module.exports = function({ resourceMonitor, healthChecker, updateManager, logEr
}); });
} }
// Dependency manager events
if (dependencyManager) {
dependencyManager.on('dependency-restart-start', (data) => {
broadcast('dependency-restart-start', data);
});
dependencyManager.on('dependency-restart-progress', (data) => {
broadcast('dependency-restart-progress', data);
});
dependencyManager.on('dependency-restart-complete', (data) => {
broadcast('dependency-restart-complete', data);
});
dependencyManager.on('dependency-restart-failed', (data) => {
broadcast('dependency-restart-failed', data);
});
}
// Auto-restart manager events
if (autoRestartManager) {
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
}
// Config drift detector events
if (driftDetector) {
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
}
// SSL monitor events
if (sslMonitor) {
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
}
// DNS propagation checker events
if (dnsPropagationChecker) {
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
}
// SSE endpoint // SSE endpoint
router.get('/stream', (req, res) => { router.get('/stream', (req, res) => {
res.writeHead(200, { res.writeHead(200, {
+2 -11
View File
@@ -372,7 +372,7 @@ module.exports = function({
// Add a new service // Add a new service
router.post('/services', asyncHandler(async (req, res) => { router.post('/services', asyncHandler(async (req, res) => {
try { try {
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body; const { id, name, logo } = req.body;
if (!id || !name) { if (!id || !name) {
throw new ValidationError('id and name are required'); throw new ValidationError('id and name are required');
@@ -391,14 +391,7 @@ module.exports = function({
throw new ConflictError(`Service "${id}" already exists`, id); throw new ConflictError(`Service "${id}" already exists`, id);
} }
const newService = { id, name, logo: logo || `/assets/${id}.png` }; services.push({ id, name, logo: logo || `/assets/${id}.png` });
// Persist optional metadata fields if provided
if (category) newService.category = category;
if (containerId) newService.containerId = containerId;
if (port) newService.port = port;
if (ip) newService.ip = ip;
if (typeof tailscaleOnly === 'boolean') newService.tailscaleOnly = tailscaleOnly;
services.push(newService);
return services; return services;
}); });
@@ -549,8 +542,6 @@ module.exports = function({
}; };
if (name) services[serviceIndex].name = name; if (name) services[serviceIndex].name = name;
if (logo) services[serviceIndex].logo = logo; if (logo) services[serviceIndex].logo = logo;
// Allow category update via update endpoint too (optional body field)
if (req.body.category !== undefined) services[serviceIndex].category = req.body.category || undefined;
results.services = 'updated'; results.services = 'updated';
} else { } else {
results.services = 'not found'; results.services = 'not found';
-113
View File
@@ -1,113 +0,0 @@
/**
* SSL Monitor Routes
* REST API endpoints for SSL certificate monitoring.
*
* @module routes/ssl-monitor
*/
const express = require('express');
const { success, error: errorResponse, notFound } = require('../response-helpers');
/**
* SSL Monitor route factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.sslMonitor - SSLMonitor instance
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @returns {express.Router}
*/
module.exports = function({ sslMonitor, asyncHandler, logError }) {
const router = express.Router();
/**
* GET /ssl/certificates
* Get all SSL certificate statuses
*/
router.get('/certificates', asyncHandler(async (req, res) => {
const status = sslMonitor.getStatus();
success(res, { certificates: status });
}, 'ssl-certificates'));
/**
* GET /ssl/certificates/:serviceId
* Get SSL certificate status for a specific service
*/
router.get('/certificates/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const certStatus = sslMonitor.getServiceCertStatus(serviceId);
if (!certStatus) {
return notFound(res, `No SSL certificate status found for service: ${serviceId}`);
}
success(res, { certificate: certStatus });
}, 'ssl-certificate-service'));
/**
* POST /ssl/check
* Trigger an on-demand check of all SSL certificates
*/
router.post('/check', asyncHandler(async (req, res) => {
const results = await sslMonitor.checkAll();
success(res, { certificates: results, message: 'SSL check completed' });
}, 'ssl-check-all'));
/**
* POST /ssl/check/:serviceId
* Check the SSL certificate for a specific service
*/
router.post('/check/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
// Look up the existing cert status to find the hostname
const existingCert = sslMonitor.getServiceCertStatus(serviceId);
if (!existingCert) {
return notFound(res, `No HTTPS URL found for service: ${serviceId}`);
}
try {
const result = await sslMonitor.checkCert(existingCert.hostname, existingCert.port);
success(res, { certificate: { ...result, serviceId } });
} catch (err) {
errorResponse(res, `Failed to check SSL certificate: ${err.message}`, 500);
}
}, 'ssl-check-service'));
/**
* GET /ssl/config
* Get current SSL monitoring configuration
*/
router.get('/config', asyncHandler(async (req, res) => {
const config = sslMonitor.getConfig();
success(res, { config });
}, 'ssl-config-get'));
/**
* POST /ssl/config
* Update SSL monitoring configuration
* Body: { enabled: boolean, intervalMs: number }
*/
router.post('/config', asyncHandler(async (req, res) => {
const { enabled, intervalMs } = req.body;
// Validate inputs
if (enabled !== undefined && typeof enabled !== 'boolean') {
return errorResponse(res, 'enabled must be a boolean', 400);
}
if (intervalMs !== undefined) {
if (typeof intervalMs !== 'number' || intervalMs < 60000) {
return errorResponse(res, 'intervalMs must be a number >= 60000 (1 minute)', 400);
}
}
const updates = {};
if (enabled !== undefined) updates.enabled = enabled;
if (intervalMs !== undefined) updates.intervalMs = intervalMs;
sslMonitor.updateConfig(updates);
const config = sslMonitor.getConfig();
success(res, { config, message: 'SSL monitoring config updated' });
}, 'ssl-config-update'));
return router;
};
View File
File mode changed.
+7 -140
View File
@@ -39,13 +39,6 @@ let dockerMaintenance, logDigest;
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ } try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ } try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
// Workflow engine (bundled workflows)
let bundledWorkflowsModule;
let workflowEngine = null;
try {
bundledWorkflowsModule = require('../bundled-workflows');
} catch (_) { /* optional module */ }
// Templates // Templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates'); const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
@@ -76,16 +69,6 @@ const recipesRoutes = require('../routes/recipes');
const themesRoutes = require('../routes/themes'); const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources'); const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events'); const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows');
const dependenciesRoutes = require('../routes/dependencies');
const DependencyManager = require('../dependency-manager');
const autoRestartRoutes = require('../routes/auto-restart');
const configDriftRoutes = require('../routes/config-drift');
const sslMonitorRoutes = require('../routes/ssl-monitor');
const { AutoRestartManager } = require('../auto-restart-manager');
const { ConfigDriftDetector } = require('../config-drift-detector');
const { SSLMonitor } = require('../ssl-monitor');
const { DNSPropagationChecker } = require('../dns-propagation');
// Constants // Constants
const { APP } = require('../constants'); const { APP } = require('../constants');
@@ -325,88 +308,9 @@ async function createApp() {
app, app,
}); });
// Initialize workflow engine if bundled-workflows is available
if (bundledWorkflowsModule && ctx.docker) {
try {
const { WorkflowEngine } = bundledWorkflowsModule;
const workflowCtx = {
docker: ctx.docker,
notification: ctx.notification,
backupManager: ctx.backupManager,
resourceMonitor: ctx.resourceMonitor,
servicesStateManager: ctx.servicesStateManager
};
workflowEngine = new WorkflowEngine(workflowCtx);
ctx.workflowEngine = workflowEngine;
log.info('app', 'Workflow engine initialized');
} catch (err) {
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
}
}
// Initialize dependency manager
const dependencyManager = new DependencyManager({
servicesStateManager,
docker: ctx.docker,
notification: ctx.notification,
log,
});
ctx.dependencyManager = dependencyManager;
log.info('app', 'Dependency manager initialized');
// Initialize auto-restart manager
const autoRestartManager = new AutoRestartManager(ctx);
ctx.autoRestartManager = autoRestartManager;
autoRestartManager.start();
log.info('app', 'Auto-restart manager initialized');
// Initialize config drift detector
const driftDetector = new ConfigDriftDetector(ctx);
ctx.driftDetector = driftDetector;
driftDetector.startPolling(300000); // 5 min
log.info('app', 'Config drift detector initialized');
// Initialize SSL monitor
const sslMonitor = new SSLMonitor(ctx);
ctx.sslMonitor = sslMonitor;
sslMonitor.start(3600000); // 1 hour
log.info('app', 'SSL monitor initialized');
// Initialize DNS propagation checker
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
ctx.dnsPropagationChecker = dnsPropagationChecker;
log.info('app', 'DNS propagation checker initialized');
// Build versioned API router // Build versioned API router
const apiRouter = express.Router(); const apiRouter = express.Router();
// Wire up notification listeners for resourceMonitor and backupManager
if (ctx.notification && ctx.resourceMonitor) {
ctx.resourceMonitor.on('alert', (alertData) => {
ctx.notification.sendAlert(alertData).catch(err => {
log.error('notification', 'Failed to send alert', { error: err.message });
});
});
ctx.resourceMonitor.on('auto-restart', (data) => {
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
});
});
}
if (ctx.notification && ctx.backupManager) {
ctx.backupManager.on('backup-complete', (data) => {
ctx.notification.send('backup-complete', data).catch(err => {
log.error('notification', 'Failed to send backup-complete', { error: err.message });
});
});
ctx.backupManager.on('backup-failed', (data) => {
ctx.notification.send('backup-failed', data).catch(err => {
log.error('notification', 'Failed to send backup-failed', { error: err.message });
});
});
}
// Mount route modules // Mount route modules
apiRouter.use(authRoutes(ctx)); apiRouter.use(authRoutes(ctx));
apiRouter.use(configRoutes(ctx)); apiRouter.use(configRoutes(ctx));
@@ -417,8 +321,7 @@ async function createApp() {
log: ctx.log, log: ctx.log,
safeErrorMessage: ctx.safeErrorMessage, safeErrorMessage: ctx.safeErrorMessage,
fetchT: ctx.fetchT, fetchT: ctx.fetchT,
credentialManager: ctx.credentialManager, credentialManager: ctx.credentialManager
dnsPropagationChecker: ctx.dnsPropagationChecker
})); }));
apiRouter.use('/notifications', notificationRoutes({ apiRouter.use('/notifications', notificationRoutes({
notification: ctx.notification, notification: ctx.notification,
@@ -427,8 +330,7 @@ async function createApp() {
apiRouter.use('/containers', containerRoutes({ apiRouter.use('/containers', containerRoutes({
docker: ctx.docker, docker: ctx.docker,
log: ctx.log, log: ctx.log,
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler
workflowEngine: ctx.workflowEngine
})); }));
apiRouter.use(serviceRoutes({ apiRouter.use(serviceRoutes({
servicesStateManager: ctx.servicesStateManager, servicesStateManager: ctx.servicesStateManager,
@@ -459,8 +361,7 @@ async function createApp() {
resourceMonitor: ctx.resourceMonitor, resourceMonitor: ctx.resourceMonitor,
docker: ctx.docker, docker: ctx.docker,
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler,
log: ctx.log, log: ctx.log
notificationManager: ctx.notification
})); }));
apiRouter.use(updatesRoutes({ apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager, updateManager: ctx.updateManager,
@@ -503,8 +404,8 @@ async function createApp() {
})); }));
apiRouter.use(backupsRoutes({ apiRouter.use(backupsRoutes({
backupManager: ctx.backupManager, backupManager: ctx.backupManager,
licenseManager: ctx.licenseManager, asyncHandler: ctx.asyncHandler,
asyncHandler: ctx.asyncHandler licenseManager: ctx.licenseManager
})); }));
apiRouter.use('/ca', caRoutes(ctx)); apiRouter.use('/ca', caRoutes(ctx));
apiRouter.use(browseRoutes({ apiRouter.use(browseRoutes({
@@ -532,41 +433,7 @@ async function createApp() {
resourceMonitor: ctx.resourceMonitor, resourceMonitor: ctx.resourceMonitor,
healthChecker: ctx.healthChecker, healthChecker: ctx.healthChecker,
updateManager: ctx.updateManager, updateManager: ctx.updateManager,
logError: ctx.logError, logError: ctx.logError
dependencyManager: ctx.dependencyManager,
autoRestartManager: ctx.autoRestartManager,
driftDetector: ctx.driftDetector,
sslMonitor: ctx.sslMonitor,
dnsPropagationChecker: ctx.dnsPropagationChecker
}));
apiRouter.use(workflowsRoutes({
workflowEngine: ctx.workflowEngine,
licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/dependencies', dependenciesRoutes({
dependencyManager: ctx.dependencyManager,
servicesStateManager: ctx.servicesStateManager,
docker: ctx.docker,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
resyncHealthChecker: ctx.resyncHealthChecker,
log: ctx.log,
}));
apiRouter.use(autoRestartRoutes({
autoRestartManager: ctx.autoRestartManager,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
apiRouter.use(configDriftRoutes({
driftDetector: ctx.driftDetector,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
apiRouter.use(sslMonitorRoutes({
sslMonitor: ctx.sslMonitor,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
})); }));
// Inline API routes // Inline API routes
-411
View File
@@ -1,411 +0,0 @@
/**
* SSL Certificate Monitor
* Periodically checks SSL certificates on services with HTTPS URLs.
* Alerts at 30, 14, and 7 days before expiry.
*
* @module ssl-monitor
*/
const tls = require('tls');
const EventEmitter = require('events');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
const { resolveServiceUrl } = require('./url-resolver');
/** Default check interval: 1 hour */
const DEFAULT_INTERVAL_MS = 3600000;
/** Alert thresholds in days */
const THRESHOLDS = {
WARNING: 30,
URGENT: 14,
CRITICAL: 7
};
/** TLS connection timeout in milliseconds */
const TLS_TIMEOUT_MS = 10000;
class SSLMonitor extends EventEmitter {
/**
* Create an SSLMonitor instance.
* @param {Object} ctx - Shared application context
* @param {Object} ctx.servicesStateManager - State manager for reading services
* @param {Function} ctx.buildServiceUrl - URL builder helper
* @param {Object} ctx.siteConfig - Site configuration
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {string} [ctx.SSL_CACHE_FILE] - Path to persist SSL cache
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
/** @type {Map<string, Object>} hostname → last cert check result */
this.certStatus = new Map();
/** @type {Map<string, number>} hostname → last notified threshold level */
this.notifiedThresholds = new Map();
/** @type {Map<string, string>} hostname → service ID mapping */
this.hostnameToServiceId = new Map();
/** @type {NodeJS.Timeout|null} */
this.intervalHandle = null;
/** Current config */
this.config = {
enabled: true,
intervalMs: DEFAULT_INTERVAL_MS
};
/** Cache file path */
this.cacheFile = ctx.SSL_CACHE_FILE ||
path.join(path.dirname(ctx.SERVICES_FILE || './data'), 'ssl-cache.json');
}
/**
* Check the SSL certificate for a given hostname and port.
* Connects via TLS with rejectUnauthorized: false to retrieve certificate info.
*
* @param {string} hostname - The hostname to check
* @param {number} [port=443] - The port to connect to
* @returns {Promise<Object>} Certificate information
*/
async checkCert(hostname, port = 443) {
return new Promise((resolve, reject) => {
const socket = tls.connect({
host: hostname,
port,
rejectUnauthorized: false,
servername: hostname,
timeout: TLS_TIMEOUT_MS
}, () => {
try {
const cert = socket.getPeerCertificate();
if (!cert || Object.keys(cert).length === 0) {
socket.destroy();
return reject(new Error(`No certificate returned for ${hostname}:${port}`));
}
const validFrom = new Date(cert.valid_from);
const validTo = new Date(cert.valid_to);
const now = new Date();
const msRemaining = validTo.getTime() - now.getTime();
const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));
const result = {
hostname,
port,
subject: cert.subject?.CN || cert.subject?.O || 'Unknown',
issuer: cert.issuer?.CN || cert.issuer?.O || 'Unknown',
validFrom: cert.valid_from,
validTo: cert.valid_to,
daysRemaining,
fingerprint: cert.fingerprint || null,
isExpiring: daysRemaining <= THRESHOLDS.WARNING,
checkedAt: new Date().toISOString()
};
socket.destroy();
resolve(result);
} catch (err) {
socket.destroy();
reject(err);
}
});
socket.on('error', (err) => {
reject(new Error(`TLS connect error for ${hostname}:${port}: ${err.message}`));
});
socket.setTimeout(TLS_TIMEOUT_MS, () => {
socket.destroy(new Error(`TLS connection timeout for ${hostname}:${port}`));
reject(new Error(`TLS connection timeout for ${hostname}:${port}`));
});
});
}
/**
* Check SSL certificates for all services that have HTTPS URLs.
* Reads services from ctx.servicesStateManager, resolves URLs, and checks each HTTPS cert.
*
* @returns {Promise<Object>} Map of hostname → cert status
*/
async checkAll() {
if (!this.config.enabled) {
this.log.info('ssl-monitor', 'SSL monitoring is disabled, skipping check');
return this.getStatus();
}
let servicesData;
try {
servicesData = await this.ctx.servicesStateManager.read();
} catch (err) {
this.log.error('ssl-monitor', 'Failed to read services', { error: err.message });
return this.getStatus();
}
const services = Array.isArray(servicesData) ? servicesData : (servicesData.services || []);
for (const service of services) {
const serviceId = service.id || service.name?.toLowerCase();
if (!serviceId) continue;
try {
const url = resolveServiceUrl(serviceId, service, this.ctx.siteConfig, this.ctx.buildServiceUrl);
if (!url) continue;
const parsed = new URL(url);
if (parsed.protocol !== 'https:') continue;
const hostname = parsed.hostname;
const port = parseInt(parsed.port) || 443;
// Map hostname back to service ID
this.hostnameToServiceId.set(hostname, serviceId);
const result = await this.checkCert(hostname, port);
// Store result
this.certStatus.set(hostname, result);
// Emit check event
this.emit('cert-check', { serviceId, hostname, result });
// Check alert thresholds
await this._checkAndNotify(hostname, result, serviceId);
} catch (err) {
this.log.warn('ssl-monitor', `Failed to check cert for service ${serviceId}`, {
error: err.message
});
}
}
// Persist results
await this._saveCache();
return this.getStatus();
}
/**
* Start periodic SSL certificate checking.
*
* @param {number} [intervalMs=3600000] - Check interval in milliseconds
*/
start(intervalMs) {
if (intervalMs !== undefined) {
this.config.intervalMs = intervalMs;
}
if (this.intervalHandle) {
this.log.warn('ssl-monitor', 'SSL monitor is already running');
return;
}
this.config.enabled = true;
// Load cached data
this._loadCache().catch(err => {
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
});
// Initial check (non-blocking)
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message });
});
// Schedule periodic checks
this.intervalHandle = setInterval(() => {
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
});
}, this.config.intervalMs);
this.log.info('ssl-monitor', 'SSL monitoring started', {
intervalMs: this.config.intervalMs
});
}
/**
* Stop periodic SSL certificate checking.
*/
stop() {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
}
this.config.enabled = false;
this.log.info('ssl-monitor', 'SSL monitoring stopped');
}
/**
* Get the current SSL certificate status for all checked hostnames.
*
* @returns {Object} Map of hostname → cert status
*/
getStatus() {
const status = {};
for (const [hostname, cert] of this.certStatus.entries()) {
status[hostname] = { ...cert };
}
return status;
}
/**
* Get the SSL certificate status for a specific service.
*
* @param {string} serviceId - The service ID to look up
* @returns {Object|null} Certificate status or null if not found
*/
getServiceCertStatus(serviceId) {
// Find hostname mapped to this service
for (const [hostname, id] of this.hostnameToServiceId.entries()) {
if (id === serviceId) {
const cert = this.certStatus.get(hostname);
return cert ? { ...cert, serviceId } : null;
}
}
return null;
}
/**
* Get current monitoring configuration.
*
* @returns {Object} Config with interval and enabled state
*/
getConfig() {
return { ...this.config };
}
/**
* Update monitoring configuration.
*
* @param {Object} updates - Config updates
* @param {boolean} [updates.enabled] - Enable/disable monitoring
* @param {number} [updates.intervalMs] - Check interval in milliseconds
*/
updateConfig(updates) {
if (typeof updates.enabled === 'boolean') {
this.config.enabled = updates.enabled;
if (!updates.enabled && this.intervalHandle) {
this.stop();
}
}
if (typeof updates.intervalMs === 'number' && updates.intervalMs >= 60000) {
this.config.intervalMs = updates.intervalMs;
// Restart interval if running
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = setInterval(() => {
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
});
}, this.config.intervalMs);
}
}
}
// ===== Private Methods =====
/**
* Check alert thresholds and send notifications if thresholds are crossed.
* Only sends one notification per threshold per hostname.
*
* @param {string} hostname
* @param {Object} certResult
* @param {string} serviceId
*/
async _checkAndNotify(hostname, certResult, serviceId) {
const { daysRemaining } = certResult;
const key = hostname;
const lastNotified = this.notifiedThresholds.get(key) || Infinity;
let level = null;
let eventType = null;
let message = null;
if (daysRemaining <= THRESHOLDS.CRITICAL) {
level = THRESHOLDS.CRITICAL;
eventType = 'cert-critical';
message = `🔒 CRITICAL: SSL certificate for ${hostname} expires in ${daysRemaining} days!`;
} else if (daysRemaining <= THRESHOLDS.URGENT) {
level = THRESHOLDS.URGENT;
eventType = 'cert-expiring';
message = `⚠️ URGENT: SSL certificate for ${hostname} expires in ${daysRemaining} days`;
} else if (daysRemaining <= THRESHOLDS.WARNING) {
level = THRESHOLDS.WARNING;
eventType = 'cert-expiring';
message = `⚠️ SSL certificate for ${hostname} expires in ${daysRemaining} days`;
}
if (level !== null && level < lastNotified) {
// New threshold crossed — send notification
this.notifiedThresholds.set(key, level);
this.emit(eventType, { hostname, serviceId, daysRemaining, level });
if (this.ctx.notification) {
try {
await this.ctx.notification.send('ssl-cert-expiry', {
text: message,
hostname,
serviceId,
daysRemaining,
level,
validTo: certResult.validTo
}, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning');
} catch (err) {
this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message });
}
}
} else if (level === null) {
// Cert is healthy — reset notification tracking
this.notifiedThresholds.delete(key);
}
}
/**
* Persist cert status cache to disk.
*/
async _saveCache() {
try {
const data = {
lastChecked: new Date().toISOString(),
certs: {},
hostnameToServiceId: Object.fromEntries(this.hostnameToServiceId)
};
for (const [hostname, cert] of this.certStatus.entries()) {
data.certs[hostname] = cert;
}
await writeJsonFile(this.cacheFile, data);
} catch (err) {
this.log.warn('ssl-monitor', 'Failed to save SSL cache', { error: err.message });
}
}
/**
* Load cert status cache from disk.
*/
async _loadCache() {
try {
const data = await readJsonFile(this.cacheFile, null);
if (data && data.certs) {
for (const [hostname, cert] of Object.entries(data.certs)) {
this.certStatus.set(hostname, cert);
}
if (data.hostnameToServiceId) {
for (const [hostname, serviceId] of Object.entries(data.hostnameToServiceId)) {
this.hostnameToServiceId.set(hostname, serviceId);
}
}
this.log.info('ssl-monitor', 'Loaded SSL cache', {
certCount: this.certStatus.size
});
}
} catch (err) {
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
}
}
}
module.exports = SSLMonitor;
@@ -155,16 +155,47 @@
return b.toFixed(1) + ' ' + units[i]; return b.toFixed(1) + ' ' + units[i];
} }
function setServicesCard() { // ----- Robust services count -----
const total = (window.APPS || []).length; // Read from multiple sources so we always have a number:
let up = 0; // 1. window.APPS (populated by grid.js after loadServices)
document.querySelectorAll('#cards .card').forEach(c => { // 2. #cards .card elements (post-buildGrid)
if (c.dataset.status === 'on') up++; // 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
}); async function fetchServicesCount() {
// Source 1+2: window.APPS / DOM cards
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
return { total: window.APPS.length, up, source: 'APPS' };
}
const cards = document.querySelectorAll('#cards .card');
if (cards.length > 0) {
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
return { total: cards.length, up, source: 'DOM' };
}
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
try {
const r = await fetch('/api/v1/services', { cache: 'no-store' });
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
const body = await r.json();
const list = (body && Array.isArray(body.services)) ? body.services
: (Array.isArray(body)) ? body
: [];
// Persist for the grid so this fallback only fires once
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
return { total: list.length, up, source: 'fetch' };
} catch (_) {
return { total: 0, up: 0, source: 'fetch-error' };
}
}
async function setServicesCard() {
const { total, up } = await fetchServicesCount();
const el = document.getElementById('dc-monitor-services'); const el = document.getElementById('dc-monitor-services');
const sub = document.getElementById('dc-monitor-services-sub'); const sub = document.getElementById('dc-monitor-services-sub');
if (el) el.textContent = `${up} / ${total}`; if (el) el.textContent = `${up} / ${total}`;
if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`; if (sub) sub.textContent = total === 0
? 'no services yet'
: `${up} online · ${total - up} offline`;
} }
function applyHealthSummary(data) { function applyHealthSummary(data) {
+56
View File
@@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
readonly DASHBOARD_DIR="${SITES_DIR}/status" readonly DASHBOARD_DIR="${SITES_DIR}/status"
readonly CONTAINER_NAME="dashcaddy-api" readonly CONTAINER_NAME="dashcaddy-api"
readonly CADDY_ADMIN_PORT=2019 readonly CADDY_ADMIN_PORT=2019
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
readonly DEFAULT_MAX_STORAGE_BYTES=""
# ---- Tunables (overridable via flags) -------------------------------------- # ---- Tunables (overridable via flags) --------------------------------------
API_PORT=3001 API_PORT=3001
LOCAL_PORT=8080 LOCAL_PORT=8080
BACKUP_DIR=""
BACKUP_LIMIT=""
# ---- Runtime state --------------------------------------------------------- # ---- Runtime state ---------------------------------------------------------
DOMAIN_MODE="" # public | custom-tld | local DOMAIN_MODE="" # public | custom-tld | local
@@ -389,6 +393,7 @@ EOF
create_directories() { create_directories() {
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets" mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
mkdir -p "${BACKUP_DIR}"
ok "Directories created" ok "Directories created"
} }
@@ -626,7 +631,41 @@ CEOF
# Docker Compose # Docker Compose
# ============================================================================ # ============================================================================
# Parse size string like "10GB" or "1TB" to bytes
parse_size_to_bytes() {
local size="$1"
local value unit
# Strip whitespace
size=$(echo "$size" | tr -d ' ')
# Extract numeric value and unit
if [[ $size =~ ^([0-9.]+)([kmgtKMGT][bb]?|[bB]?)$ ]]; then
value="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
# Normalize unit to uppercase without 'B' suffix for simplicity
unit=$(echo "$unit" | tr '[:lower:]' '[:upper:]')
case "$unit" in
K|KB) echo $((value * 1024)) ;;
M|MB) echo $((value * 1024 * 1024)) ;;
G|GB) echo $((value * 1024 * 1024 * 1024)) ;;
T|TB) echo $((value * 1024 * 1024 * 1024 * 1024)) ;;
*) echo "$value" ;;
esac
else
# Not recognized, treat as raw bytes
echo "$size"
fi
}
generate_docker_compose() { generate_docker_compose() {
# Convert BACKUP_LIMIT to bytes if set (e.g., "10GB" -> 10737418240)
local backup_limit_bytes=""
if [[ -n "$BACKUP_LIMIT" ]]; then
backup_limit_bytes=$(parse_size_to_bytes "$BACKUP_LIMIT")
fi
cat > "${API_DIR}/docker-compose.yml" <<DCEOF cat > "${API_DIR}/docker-compose.yml" <<DCEOF
services: services:
dashcaddy-api: dashcaddy-api:
@@ -648,6 +687,7 @@ services:
- ${DASHBOARD_DIR}:/app/dashboard:rw - ${DASHBOARD_DIR}:/app/dashboard:rw
- /opt/dashcaddy/updates:/app/updates:rw - /opt/dashcaddy/updates:/app/updates:rw
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- dashcaddy-backups:/app/backups
environment: environment:
- CADDYFILE_PATH=/caddyfile - CADDYFILE_PATH=/caddyfile
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT} - CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
@@ -665,6 +705,10 @@ services:
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates - DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
- DASHCADDY_API_SOURCE_DIR=${API_DIR} - DASHCADDY_API_SOURCE_DIR=${API_DIR}
- DASHCADDY_FRONTEND_DIR=/app/dashboard - DASHCADDY_FRONTEND_DIR=/app/dashboard
- BACKUP_DIR=/app/backups
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
- BACKUP_CONFIG_FILE=/app/backup-config.json
- BACKUP_HISTORY_FILE=/app/backup-history.json
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
@@ -673,6 +717,14 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes:
dashcaddy-backups:
driver: local
driver_opts:
type: none
o: bind
device: ${BACKUP_DIR}
DCEOF DCEOF
ok "docker-compose.yml generated" ok "docker-compose.yml generated"
@@ -880,6 +932,8 @@ parse_args() {
--skip-caddy) SKIP_CADDY=true; shift ;; --skip-caddy) SKIP_CADDY=true; shift ;;
--uninstall) UNINSTALL=true; shift ;; --uninstall) UNINSTALL=true; shift ;;
--keep-config) KEEP_CONFIG=true; shift ;; --keep-config) KEEP_CONFIG=true; shift ;;
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
--yes|-y) AUTO_YES=true; shift ;; --yes|-y) AUTO_YES=true; shift ;;
--help|-h) print_help; exit 0 ;; --help|-h) print_help; exit 0 ;;
*) warn "Unknown option: $1 (ignored)"; shift ;; *) warn "Unknown option: $1 (ignored)"; shift ;;
@@ -914,6 +968,8 @@ print_help() {
--source PATH Use local source files --source PATH Use local source files
--skip-docker Already have Docker --skip-docker Already have Docker
--skip-caddy Already have Caddy --skip-caddy Already have Caddy
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
--uninstall Remove DashCaddy --uninstall Remove DashCaddy
--keep-config Keep configs during uninstall --keep-config Keep configs during uninstall
--yes Skip confirmations --yes Skip confirmations
+21 -4
View File
@@ -226,17 +226,34 @@ class ConfigManager {
* @returns {Promise<Object>} Disk space info * @returns {Promise<Object>} Disk space info
*/ */
async getDiskSpace(testPath) { async getDiskSpace(testPath) {
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
try { try {
const stats = await fs.stat(testPath); const fsPromises = require('fs').promises;
const pathModule = require('path');
// Ensure directory exists
await fsPromises.mkdir(testPath, { recursive: true });
// Use statfs for true disk space (works on all filesystems: ext4, Btrfs, XFS, ZFS, APFS, NTFS)
const stats = await fsPromises.statfs(testPath);
const totalBytes = stats.blocks * stats.bsize;
const freeBytes = stats.bfree * stats.bsize;
const availableBytes = stats.bavail * stats.bsize; // Available to non-root users
const usedBytes = totalBytes - freeBytes;
return { return {
available: true, available: true,
path: testPath path: testPath,
total: totalBytes,
used: usedBytes,
free: freeBytes,
availableBytes: availableBytes,
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
}; };
} catch (error) { } catch (error) {
return { return {
available: false, available: false,
path: testPath,
error: error.message error: error.message
}; };
} }
@@ -62,6 +62,11 @@ const state = {
installPath: '', installPath: '',
health: null health: null
}, },
// Backup configuration
backup: {
maxStorageGB: 10,
backupDir: ''
},
// Uninstall mode // Uninstall mode
uninstallMode: false, uninstallMode: false,
uninstall: { uninstall: {
@@ -373,6 +378,24 @@ function updateBranding(field, value) {
if (field === 'primaryColor') render(); if (field === 'primaryColor') render();
} }
// Backup functions
function updateBackup(field, value) {
state.backup[field] = value;
render();
}
async function selectBackupDir() {
try {
const result = await window.electronAPI.selectFolder();
if (result.success && result.path) {
state.backup.backupDir = result.path;
render();
}
} catch (err) {
console.error('Backup dir selection failed:', err);
}
}
async function selectLogo() { async function selectLogo() {
try { try {
const result = await window.electronAPI.selectFile({ const result = await window.electronAPI.selectFile({
@@ -420,6 +443,10 @@ async function startInstallation() {
password: state.dns.password, password: state.dns.password,
token: state.dns.token token: state.dns.token
} : null, } : null,
backup: {
maxStorageGB: state.backup.maxStorageGB,
backupDir: state.backup.backupDir || null
},
autoStart: true autoStart: true
}); });
} catch (err) { } catch (err) {
@@ -963,6 +990,31 @@ function renderDashboardSetup() {
<p class="hint">Port for the DashCaddy API server (default: 3001)</p> <p class="hint">Port for the DashCaddy API server (default: 3001)</p>
</div> </div>
` : ''} ` : ''}
<div class="folder-input">
<label>Backup Storage Limit (GB)</label>
<div class="input-row">
<input type="number"
value="${state.backup.maxStorageGB}"
min="1" max="10240"
oninput="updateBackup('maxStorageGB', parseInt(this.value) || 10)">
</div>
<p class="hint">Maximum storage for backups in GB (default: 10, max: 10TB)</p>
</div>
${state.tier !== 'basic' ? `
<div class="folder-input">
<label>Backup Directory</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.backup.backupDir)}"
readonly
placeholder="Default: $INSTALL_DIR/backups">
<button class="btn-browse" onclick="selectBackupDir()">Browse...</button>
</div>
<p class="hint">Where backup files are stored on the host</p>
</div>
` : ''}
</div> </div>
</div> </div>
`; `;
@@ -7,15 +7,28 @@ services:
volumes: volumes:
- {{API_PATH}}:/app - {{API_PATH}}:/app
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- dashcaddy-backups:/app/backups
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT={{API_PORT}} - PORT={{API_PORT}}
- SERVICES_FILE=/app/services.json - SERVICES_FILE=/app/services.json
- CADDY_ADMIN_URL=http://host.docker.internal:2019 - CADDY_ADMIN_URL=http://host.docker.internal:2019
- BACKUP_DIR=/app/backups
- BACKUP_MAX_STORAGE_BYTES={{BACKUP_MAX_STORAGE_BYTES}}
- BACKUP_CONFIG_FILE=/app/backup-config.json
- BACKUP_HISTORY_FILE=/app/backup-history.json
restart: unless-stopped restart: unless-stopped
networks: networks:
- dashcaddy - dashcaddy
volumes:
dashcaddy-backups:
driver: local
driver_opts:
type: none
o: bind
device: {{BACKUP_DIR}}
networks: networks:
dashcaddy: dashcaddy:
driver: bridge driver: bridge
-83
View File
@@ -1,83 +0,0 @@
#!/bin/bash
# =============================================================================
# DashCaddy Gitea — Off-host backup to Dropbox
# =============================================================================
# - Stops gitea container briefly to ensure SQLite DB consistency
# - Syncs /var/lib/docker/volumes/gitea-data to dropbox:/Apps/dashcaddy-gitea-backups/<date>/
# - Date-stamped snapshots (one per day), kept for 7 days locally
# - Restarts gitea even if sync fails
# - Logs to /var/log/gitea-backup.log
# =============================================================================
set -u # don't use -e: we want to always restart gitea
LOG=/var/log/gitea-backup.log
DATA_SRC=/var/lib/docker/volumes/gitea-data/_data
DEST="dropbox:/Apps/dashcaddy-gitea-backups"
TODAY=$(date -u +%Y-%m-%d)
BACKUP_PATH="${DEST}/${TODAY}"
RETENTION_DAYS=7
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG"; }
log "=== Backup start ==="
# 0. Sanity checks
if [ ! -d "$DATA_SRC" ]; then
log "ERROR: data dir $DATA_SRC missing"
exit 1
fi
# 1. Stop gitea to flush SQLite
log "Stopping gitea container..."
docker stop gitea >> "$LOG" 2>&1
STOP_RC=$?
if [ $STOP_RC -ne 0 ]; then
log "WARNING: docker stop returned $STOP_RC — container may not be running"
fi
# 2. Sync (use copy so source files are preserved as-is, no --delete)
log "Syncing $DATA_SRC -> $BACKUP_PATH"
rclone copy "$DATA_SRC" "$BACKUP_PATH" \
--transfers 4 \
--checkers 8 \
--retries 3 \
--low-level-retries 10 \
--stats 30s \
--log-file "$LOG" \
--log-level INFO
SYNC_RC=$?
# 3. Always restart gitea
log "Starting gitea container..."
docker start gitea >> "$LOG" 2>&1
START_RC=$?
# Wait for gitea to be ready
for i in {1..30}; do
if curl -sf http://localhost:3000/api/v1/version > /dev/null 2>&1; then
log "Gitea is up after ${i}s"
break
fi
sleep 1
done
# 4. Cleanup old backups (older than RETENTION_DAYS)
log "Pruning local + remote snapshots older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -u -d "${RETENTION_DAYS} days ago" +%Y-%m-%d)
rclone lsf "$DEST/" --dirs-only 2>/dev/null | while read -r d; do
# rclone returns names with trailing /
name="${d%/}"
if [[ "$name" < "$CUTOFF" ]]; then
log " removing old: $name"
rclone purge "${DEST}/${name}" >> "$LOG" 2>&1
fi
done
# 5. Report
if [ $SYNC_RC -eq 0 ] && [ $START_RC -eq 0 ]; then
log "=== Backup OK ==="
exit 0
else
log "=== Backup completed with errors (sync=$SYNC_RC, start=$START_RC) ==="
exit 1
fi
-76
View File
@@ -1,76 +0,0 @@
#!/bin/bash
# Samihost fail2ban watchdog — auto-unban whitelisted IPs and keep ignoreip list in sync.
# Deployed to /usr/local/bin/samihost-fail2ban-watchdog.sh on 194.163.161.162
# Cron: every 30 min (0,30 * * * *)
set -euo pipefail
JAIL_LOCAL=/etc/fail2ban/jail.local
BACKUP=/etc/fail2ban/jail.local.watchdog.bak
EXPECTED_IGNOREIP="127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 fe80::/10 100.64.0.0/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162"
LOG=/var/log/samihost-fail2ban-watchdog.log
TELEGRAM_LOG=/tmp/fail2ban-watchdog-last-action
ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
log() { echo "$(ts) $*" | tee -a "$LOG"; }
mkdir -p "$(dirname "$LOG")"
touch "$LOG"
# --- 1. Verify ignoreip line is intact and matches expected ---
CURRENT=$(grep '^ignoreip' "$JAIL_LOCAL" | sed 's/^ignoreip[[:space:]]*=[[:space:]]*//' || true)
EXPECTED_NORMALIZED=$(echo "$EXPECTED_IGNOREIP" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
CURRENT_NORMALIZED=$(echo "$CURRENT" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
if [ "$CURRENT_NORMALIZED" != "$EXPECTED_NORMALIZED" ]; then
log "ALERT: ignoreip line drifted. Restoring."
cp "$JAIL_LOCAL" "$BACKUP"
sed -i "s|^ignoreip = .*|ignoreip = $EXPECTED_IGNOREIP|" "$JAIL_LOCAL"
fail2ban-client reload
echo "ignoreip restored at $(ts)" > "$TELEGRAM_LOG"
log "ignoreip restored, fail2ban reloaded"
fi
# --- 2. Unban any currently-banned IPs that match our trusted set ---
BANNED=$(fail2ban-client status sshd 2>/dev/null | awk -F: '/Banned IP list/{print $2}' | tr ' ' '\n' | grep -v '^$' || true)
UNBANNED=0
for ip in $BANNED; do
# Match against any trusted network
is_trusted=0
for net in 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 ::1 fc00::/7 fe80::/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162; do
if [[ "$net" == *"/"* ]]; then
# CIDR match (simple IPv4 only — IPv6 needs python or ipcalc, skip for now)
base="${net%/*}"
mask="${net#*/}"
if [[ "$ip" == "$base"* ]] || python3 -c "import ipaddress,sys; sys.exit(0 if ipaddress.ip_address('$ip') in ipaddress.ip_network('$net', strict=False) else 1)" 2>/dev/null; then
is_trusted=1
break
fi
else
if [ "$ip" = "$net" ]; then
is_trusted=1
break
fi
fi
done
if [ "$is_trusted" = "1" ]; then
if fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1; then
log "auto-unbanned trusted IP: $ip"
UNBANNED=$((UNBANNED+1))
fi
fi
done
[ "$UNBANNED" -gt 0 ] && echo "auto-unbanned $UNBANNED trusted IPs at $(ts)" > "$TELEGRAM_LOG"
# --- 3. Cap the ban count — if more than 200 are banned, mass-unban stale ones ---
TOTAL_BANNED=$(fail2ban-client status sshd 2>/dev/null | awk '/Currently banned/{print $NF}' || echo 0)
if [ "$TOTAL_BANNED" -gt 200 ]; then
log "ALERT: $TOTAL_BANNED IPs banned. Mass-unbanning all."
for ip in $BANNED; do
fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1 || true
done
echo "mass-unbanned $TOTAL_BANNED stale bans at $(ts)" > "$TELEGRAM_LOG"
fi
log "watchdog run complete (unbanned=$UNBANNED, total_banned=$TOTAL_BANNED)"
-1
View File
@@ -72,7 +72,6 @@ const bundles = {
], ],
'init.js': [ 'init.js': [
JS('core', 'init.js'), JS('core', 'init.js'),
JS('monitoring-widgets.js'),
JS('keyboard-shortcuts.js'), JS('keyboard-shortcuts.js'),
], ],
}; };
+87 -114
View File
@@ -1,4 +1,4 @@
(function(o){"use strict";class f{constructor(){this.errors=[],this.maxErrors=50}logError(y,l,r={}){const h={timestamp:new Date().toISOString(),context:y,message:l instanceof Error?l.message:l,stack:l instanceof Error?l.stack:null,metadata:r};this.errors.push(h),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${y}:`,l,r)}recoverFromError(y,l){switch(this.classifyError(y)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",y,{currentStep:l}),{action:"SKIP_STEP",nextStep:l+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",y),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",y),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",y,{currentStep:l}),{action:"SKIP_STEP",nextStep:l+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",y),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",y,{currentStep:l}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(y){const l=y.message||y.toString();return l.includes("element")&&l.includes("not found")?"ELEMENT_NOT_FOUND":l.includes("storage")||l.includes("quota")?"STORAGE_UNAVAILABLE":l.includes("driver")||l.includes("undefined")?"DRIVER_NOT_LOADED":l.includes("invalid")||l.includes("validation")?"INVALID_TOOLTIP":l.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const y={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(l=>{y.byContext[l.context]=(y.byContext[l.context]||0)+1;const r=this.classifyError({message:l.message});y.byType[r]=(y.byType[r]||0)+1}),y}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const y=document.createElement("div");return y.id="onboarding-fallback",y.style.cssText=` (function(o){"use strict";class f{constructor(){this.errors=[],this.maxErrors=50}logError(g,i,s={}){const h={timestamp:new Date().toISOString(),context:g,message:i instanceof Error?i.message:i,stack:i instanceof Error?i.stack:null,metadata:s};this.errors.push(h),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${g}:`,i,s)}recoverFromError(g,i){switch(this.classifyError(g)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",g,{currentStep:i}),{action:"SKIP_STEP",nextStep:i+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",g),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",g),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",g,{currentStep:i}),{action:"SKIP_STEP",nextStep:i+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",g),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",g,{currentStep:i}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(g){const i=g.message||g.toString();return i.includes("element")&&i.includes("not found")?"ELEMENT_NOT_FOUND":i.includes("storage")||i.includes("quota")?"STORAGE_UNAVAILABLE":i.includes("driver")||i.includes("undefined")?"DRIVER_NOT_LOADED":i.includes("invalid")||i.includes("validation")?"INVALID_TOOLTIP":i.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const g={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(i=>{g.byContext[i.context]=(g.byContext[i.context]||0)+1;const s=this.classifyError({message:i.message});g.byType[s]=(g.byType[s]||0)+1}),g}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const g=document.createElement("div");return g.id="onboarding-fallback",g.style.cssText=`
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -10,44 +10,44 @@
z-index: 9999; z-index: 9999;
max-width: 300px; max-width: 300px;
font-size: 14px; font-size: 14px;
`,y.innerHTML=` `,g.innerHTML=`
<strong>Welcome to DashCaddy!</strong><br> <strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;"> <p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely. The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started. Check the documentation for help getting started.
</p> </p>
`,document.body.appendChild(y),setTimeout(()=>{y.parentNode&&y.parentNode.removeChild(y)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const y={data:{},getItem(l){return this.data[l]||null},setItem(l,r){this.data[l]=r},removeItem(l){delete this.data[l]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),y}sendToErrorTracking(y){}}o.ErrorHandler=f,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const y=await fetch("/api/v1/config");if(y.ok){const l=await y.json();if(l.tld&&(SITE.tld=l.tld.startsWith(".")?l.tld:"."+l.tld),l.dns&&(SITE.dnsIp=l.dns.ip||"",SITE.dnsPort=l.dns.port||DC.DEFAULTS.DNS_PORT),l.dnsServers&&typeof l.dnsServers=="object")for(const[h,a]of Object.entries(l.dnsServers))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(SITE.dnsServers[h]=a);l.configurationType&&(SITE.configurationType=l.configurationType),l.domain&&(SITE.domain=l.domain),l.defaults&&(SITE.defaults=l.defaults),l.routingMode&&(SITE.routingMode=l.routingMode),SITE.onboardingCompleted=l.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const r=document.getElementById("manage-tokens");r&&(r.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(y=>y.textContent=SITE.tld);const f=document.getElementById("edit-tld-suffix");f&&(f.textContent=SITE.tld);const u=document.getElementById("external-proxy-ip");u&&SITE.dnsIp&&(u.value=SITE.dnsIp,u.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const f=SITE.dnsServers[o];return f?`${f.ip}:${f.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,f]of Object.entries(SITE.dnsServers))if(f.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const f=Object.keys(SITE.dnsServers);if(!f.length)return;const u='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',y=o.firstElementChild;f.forEach(l=>{const r=escapeHtml(l),h=escapeHtml((SITE.dnsServers[l].name||l).toUpperCase()),a=document.createElement("div");a.className="card",a.setAttribute("data-app",l),a.setAttribute("data-status","off"),a.innerHTML=`<span id="${r}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${u}</div><span class="name">${h}</span><span class="spacer"></span><span id="${r}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${r}-time" class="response-time">--</span></div><div class="health-row" id="health-${r}"><span id="uptime-${r}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${r}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${r}-restart" class="restart-btn">Restart</button><button id="${r}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${r}-open">Open</button><button id="${r}-logs" class="logs-btn">Logs</button><button id="${r}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(a,y)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.jsLine truncated `,document.body.appendChild(g),setTimeout(()=>{g.parentNode&&g.parentNode.removeChild(g)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const g={data:{},getItem(i){return this.data[i]||null},setItem(i,s){this.data[i]=s},removeItem(i){delete this.data[i]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),g}sendToErrorTracking(g){}}o.ErrorHandler=f,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const g=await fetch("/api/v1/config");if(g.ok){const i=await g.json();if(i.tld&&(SITE.tld=i.tld.startsWith(".")?i.tld:"."+i.tld),i.dns&&(SITE.dnsIp=i.dns.ip||"",SITE.dnsPort=i.dns.port||DC.DEFAULTS.DNS_PORT),i.dnsServers&&typeof i.dnsServers=="object")for(const[h,n]of Object.entries(i.dnsServers))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(SITE.dnsServers[h]=n);i.configurationType&&(SITE.configurationType=i.configurationType),i.domain&&(SITE.domain=i.domain),i.defaults&&(SITE.defaults=i.defaults),i.routingMode&&(SITE.routingMode=i.routingMode),SITE.onboardingCompleted=i.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const s=document.getElementById("manage-tokens");s&&(s.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(g=>g.textContent=SITE.tld);const f=document.getElementById("edit-tld-suffix");f&&(f.textContent=SITE.tld);const u=document.getElementById("external-proxy-ip");u&&SITE.dnsIp&&(u.value=SITE.dnsIp,u.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const f=SITE.dnsServers[o];return f?`${f.ip}:${f.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,f]of Object.entries(SITE.dnsServers))if(f.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const f=Object.keys(SITE.dnsServers);if(!f.length)return;const u='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',g=o.firstElementChild;f.forEach(i=>{const s=escapeHtml(i),h=escapeHtml((SITE.dnsServers[i].name||i).toUpperCase()),n=document.createElement("div");n.className="card",n.setAttribute("data-app",i),n.setAttribute("data-status","off"),n.innerHTML=`<span id="${s}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${u}</div><span class="name">${h}</span><span class="spacer"></span><span id="${s}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${s}-time" class="response-time">--</span></div><div class="health-row" id="health-${s}"><span id="uptime-${s}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${s}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${s}-restart" class="restart-btn">Restart</button><button id="${s}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${s}-open">Open</button><button id="${s}-logs" class="logs-btn">Logs</button><button id="${s}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(n,g)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.jsLine truncated
position: fixed; top: 20px; right: 20px; position: fixed; top: 20px; right: 20px;
background: ${r.bg}; color: ${r.fg}; background: ${s.bg}; color: ${s.fg};
padding: 16px 24px; border-radius: 8px; padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3); box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out; z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px; max-width: 400px; white-space: pre-line; font-size: 14px;
`,document.body.appendChild(h),u>0&&setTimeout(()=>h.remove(),u)}function timeAgo(o){const f=Date.now()-new Date(o).getTime();return f<6e4?"just now":f<36e5?Math.floor(f/6e4)+"m ago":f<864e5?Math.floor(f/36e5)+"h ago":Math.floor(f/864e5)+"d ago"}function safeGet(o,f=null){try{const u=localStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSet(o,f){try{localStorage.setItem(o,f)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,f=null){try{const u=sessionStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSessionSet(o,f){try{sessionStorage.setItem(o,f)}catch{}}function safeGetJSON(o,f=null){try{const u=localStorage.getItem(o);return u?JSON.parse(u):f}catch{return f}}function escapeHtml(o){return String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(o,f){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",f)}const DC_BUS={_handlers:{},on(o,f){var u;((u=this._handlers)[o]||(u[o]=[])).push(f)},off(o,f){this._handlers[o]=this._handlers[o]?.filter(u=>u!==f)},emit(o,f){this._handlers[o]?.forEach(u=>u(f))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(f=>f.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const f=this._apps.findIndex(u=>u.id===o);return f>-1&&(this._apps.splice(f,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),f>-1},updateApp(o,f){const u=this._apps.find(y=>y.id===o);if(u){for(const[y,l]of Object.entries(f))y!=="__proto__"&&y!=="constructor"&&y!=="prototype"&&(u[y]=l);DC_BUS.emit("apps:changed",this._apps)}return u}};(function(){function o(){const y=document.createElement("div");return y.className="skeleton-card",y.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',y}function f(y){const l=document.getElementById("cards");if(!(!l||l.querySelector(".card"))){y=y||6;for(let r=0;r<y;r++){const h=o();l.appendChild(h),setTimeout(function(){h.classList.add("loaded")},r*60)}}}function u(){const y=document.getElementById("cards");if(y){var l=y.querySelectorAll(".skeleton-card");l.length&&(l.forEach(function(r,h){setTimeout(function(){r.style.opacity="0",r.style.transform="translateY(-10px)"},h*25)}),setTimeout(function(){l.forEach(function(r){r.parentNode&&r.remove()})},l.length*25+300))}}window.SkeletonLoader={show:f,hide:u}})(),(function(){var o="theme",f="user-themes",u="custom-theme",y=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],l=y.slice(),r=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],h=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],a=["fg-muted","hover","card-hover","base","success","error","warning"],b={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated `,document.body.appendChild(h),u>0&&setTimeout(()=>h.remove(),u)}function timeAgo(o){const f=Date.now()-new Date(o).getTime();return f<6e4?"just now":f<36e5?Math.floor(f/6e4)+"m ago":f<864e5?Math.floor(f/36e5)+"h ago":Math.floor(f/864e5)+"d ago"}function safeGet(o,f=null){try{const u=localStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSet(o,f){try{localStorage.setItem(o,f)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,f=null){try{const u=sessionStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSessionSet(o,f){try{sessionStorage.setItem(o,f)}catch{}}function safeGetJSON(o,f=null){try{const u=localStorage.getItem(o);return u?JSON.parse(u):f}catch{return f}}function escapeHtml(o){return String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(o,f){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",f)}const DC_BUS={_handlers:{},on(o,f){var u;((u=this._handlers)[o]||(u[o]=[])).push(f)},off(o,f){this._handlers[o]=this._handlers[o]?.filter(u=>u!==f)},emit(o,f){this._handlers[o]?.forEach(u=>u(f))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(f=>f.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const f=this._apps.findIndex(u=>u.id===o);return f>-1&&(this._apps.splice(f,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),f>-1},updateApp(o,f){const u=this._apps.find(g=>g.id===o);if(u){for(const[g,i]of Object.entries(f))g!=="__proto__"&&g!=="constructor"&&g!=="prototype"&&(u[g]=i);DC_BUS.emit("apps:changed",this._apps)}return u}};(function(){function o(){const g=document.createElement("div");return g.className="skeleton-card",g.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',g}function f(g){const i=document.getElementById("cards");if(!(!i||i.querySelector(".card"))){g=g||6;for(let s=0;s<g;s++){const h=o();i.appendChild(h),setTimeout(function(){h.classList.add("loaded")},s*60)}}}function u(){const g=document.getElementById("cards");if(g){var i=g.querySelectorAll(".skeleton-card");i.length&&(i.forEach(function(s,h){setTimeout(function(){s.style.opacity="0",s.style.transform="translateY(-10px)"},h*25)}),setTimeout(function(){i.forEach(function(s){s.parentNode&&s.remove()})},i.length*25+300))}}window.SkeletonLoader={show:f,hide:u}})(),(function(){var o="theme",f="user-themes",u="custom-theme",g=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],i=g.slice(),s=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],h=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],n=["fg-muted","hover","card-hover","base","success","error","warning"],w={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
background: background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .08), transparent 60%), radial-gradient(1200px 800px at 10% -10%, rgba(`+b.r+","+b.g+","+b.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+w.r+","+w.g+","+w.b+`, .05), transparent 55%), radial-gradient(1000px 700px at 110% 10%, rgba(`+b.r+","+b.g+","+b.b+`, .05), transparent 55%),
var(--bg); var(--bg);
} }
`:":root."+S+` body { `:":root."+E+` body {
background: background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+w.r+","+w.g+","+w.b+`, .10), transparent 60%), radial-gradient(1200px 900px at 8% -12%, rgba(`+b.r+","+b.g+","+b.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .07), transparent 55%), radial-gradient(1000px 700px at 110% -10%, rgba(`+b.r+","+b.g+","+b.b+`, .07), transparent 55%),
var(--bg); var(--bg);
} }
`}function p(S,B){var $=B.lightBg||B.bg&&g(B.bg)>.4;return $?":root."+S+` button:hover { `}function p(E,L){var $=L.lightBg||L.bg&&y(L.bg)>.4;return $?":root."+E+` button:hover {
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
border-color: rgba(0, 0, 0, .15); border-color: rgba(0, 0, 0, .15);
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8); box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
} }
`:":root."+S+` button:hover { `:":root."+E+` button:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
} }
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function c(){r.forEach(function(S){document.documentElement.style.removeProperty("--"+S)})}function v(S,B){var $=S.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),y.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(f,{}),w=$,C=2;x[$]&&$!==B;)$=w+"-"+C++;return $}function i(S){var B=document.getElementById("user-theme-styles");B&&B.remove(),l.length=y.length,Object.keys(b).forEach(function(k){y.indexOf(k)===-1&&delete b[k]});var $=S||safeGetJSON(f,{}),x=Object.keys($);if(x=x.filter(function(k){return y.indexOf(k)===-1}),!!x.length){var w="";x.forEach(function(k){var I=$[k];l.indexOf(k)===-1&&l.push(k);var A={};r.forEach(function(D){I[D]&&(A[D]=I[D])}),A["card-bg"]=I["card-base"]||I.bg,I.lightBg&&(A.lightBg=!0);var O=e(A);a.forEach(function(D){!A[D]&&O[D]&&(A[D]=O[D])}),b[k]=A,w+=":root."+k+` { `}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function c(){s.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function v(E,L){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),g.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(f,{}),b=$,I=2;x[$]&&$!==L;)$=b+"-"+I++;return $}function d(E){var L=document.getElementById("user-theme-styles");L&&L.remove(),i.length=g.length,Object.keys(w).forEach(function(S){g.indexOf(S)===-1&&delete w[S]});var $=E||safeGetJSON(f,{}),x=Object.keys($);if(x=x.filter(function(S){return g.indexOf(S)===-1}),!!x.length){var b="";x.forEach(function(S){var C=$[S];i.indexOf(S)===-1&&i.push(S);var P={};s.forEach(function(O){C[O]&&(P[O]=C[O])}),P["card-bg"]=C["card-base"]||C.bg,C.lightBg&&(P.lightBg=!0);var D=e(P);n.forEach(function(O){!P[O]&&D[O]&&(P[O]=D[O])}),w[S]=P,b+=":root."+S+` {
`,r.forEach(function(D){A[D]&&(w+=" --"+D+": "+A[D]+`; `,s.forEach(function(O){P[O]&&(b+=" --"+O+": "+P[O]+`;
`)}),w+=`} `)}),b+=`}
`,w+=s(k,A),w+=p(k,A)});var C=document.createElement("style");C.id="user-theme-styles",C.textContent=w,document.head.appendChild(C)}}function E(){secureFetch("/api/v1/themes").then(function(S){return S.json()}).then(function(S){if(!(!S.success||!S.themes)){var B=S.themes,$=safeGetJSON(f,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(f,JSON.stringify(B)),i(B);var x=safeGet(o);x&&l.indexOf(x)!==-1&&L(x)}}}).catch(function(){})}function T(){var S=safeGetJSON(u);if(S){var B=S.name||"Custom",$=v(B),x={name:B};r.forEach(function(k){S[k]&&(x[k]=S[k])});var w=safeGetJSON(f,{});w[$]=x,safeSet(f,JSON.stringify(w)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(u);var C={};r.forEach(function(k){x[k]&&(C[k]=x[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:C})}).catch(function(){})}}function L(S){document.documentElement.classList.add("theme-transitioning"),l.forEach(function(w){w!=="dark"&&document.documentElement.classList.remove(w)}),c(),S!=="dark"&&document.documentElement.classList.add(S),safeSet(o,S);var B=b[S],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var x=B&&B.lightBg;!x&&B&&B.bg&&(x=g(B.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),i();var P=safeGet(o);P==="red"&&(P="black",safeSet(o,"black")),P&&P!=="dark"&&l.indexOf(P)===-1&&(P=null),L(P||t()),E(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(S){safeGet(o)||L(S.matches?"dark":"light")}),window.THEMES=l,window.BUILTIN_THEMES=y,window.THEME_COLORS=b,window.THEME_PROPS=r,window.BASE_PROPS=h,window.DERIVED_PROPS=a,window.USER_THEMES_KEY=f,window.applyTheme=L,window.clearCustomProperties=c,window.injectUserThemeStyles=i,window.syncThemesFromServer=E,window.slugifyThemeName=v,window.getActiveTheme=function(){return safeGet(o)||t()},window.deriveExtendedColors=e,window.hexToRgb=m,window.rgbToHex=n,window.blendColors=d})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const b=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!b)return;const m=(.299*+b[0]+.587*+b[1]+.114*+b[2])/255,n=h.querySelector(".totp-logo-dark"),d=h.querySelector(".totp-logo-light");n&&(n.style.display=m>.5?"none":""),d&&(d.style.display=m>.5?"":"none")}function f(){const h=document.getElementById("totp-overlay");if(h){h.classList.add("show"),setTimeout(o,50);const a=h.querySelector(".totp-digits input");a&&setTimeout(()=>a.focus(),100)}}function u(){const h=document.getElementById("totp-overlay");h&&h.classList.remove("show")}const y=document.getElementById("totp-digits");if(y){const h=y.querySelectorAll("input");h.forEach((a,b)=>{a.addEventListener("input",m=>{const n=m.target.value.replace(/\D/g,"");m.target.value=n.slice(0,1),n&&b<h.length-1&&h[b+1].focus();const d=Array.from(h).map(g=>g.value).join("");d.length===6&&l(d)}),a.addEventListener("keydown",m=>{m.key==="Backspace"&&!m.target.value&&b>0&&(h[b-1].focus(),h[b-1].value="")}),a.addEventListener("paste",m=>{m.preventDefault();const n=(m.clipboardData.getData("text")||"").replace(/\D/g,"");n.length>=6&&(h.forEach((d,g)=>{d.value=n[g]||""}),h[5].focus(),l(n.slice(0,6)))})})}async function l(h){const a=document.getElementById("totp-error");a.textContent="Verifying...",a.className="totp-error verifying";try{const m=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();if(m.success){a.textContent="",m.csrfToken&&(csrfToken=m.csrfToken),u();const n=safeSessionGet("totp_redirect");if(n){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=n;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{a.textContent=m.error||"Invalid code",a.className="totp-error";const n=document.querySelectorAll("#totp-digits input");n.forEach(d=>{d.value=""}),n[0]?.focus()}}catch{a.textContent="Connection error",a.className="totp-error"}}const r=new URLSearchParams(window.location.search);if(r.get("auth")==="required"){const h=r.get("return");if(h)try{const a=new URL(h,window.location.origin),b=a.hostname,m=a.origin===window.location.origin,n=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,d=b.endsWith(n)||b===n.substring(1);(m||d)&&safeSessionSet("totp_redirect",h)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=f})(),(function(){const o=new ErrorHandler;injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal"> `,b+=a(S,P),b+=p(S,P)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=b,document.head.appendChild(I)}}function k(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var L=E.themes,$=safeGetJSON(f,{});if(JSON.stringify(L)!==JSON.stringify($)){safeSet(f,JSON.stringify(L)),d(L);var x=safeGet(o);x&&i.indexOf(x)!==-1&&T(x)}}}).catch(function(){})}function B(){var E=safeGetJSON(u);if(E){var L=E.name||"Custom",$=v(L),x={name:L};s.forEach(function(S){E[S]&&(x[S]=E[S])});var b=safeGetJSON(f,{});b[$]=x,safeSet(f,JSON.stringify(b)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(u);var I={};s.forEach(function(S){x[S]&&(I[S]=x[S])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:L,colors:I})}).catch(function(){})}}function T(E){document.documentElement.classList.add("theme-transitioning"),i.forEach(function(b){b!=="dark"&&document.documentElement.classList.remove(b)}),c(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(o,E);var L=w[E],$=document.querySelector('meta[name="theme-color"]');$&&L&&$.setAttribute("content",L.bg);var x=L&&L.lightBg;!x&&L&&L.bg&&(x=y(L.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),d();var A=safeGet(o);A==="red"&&(A="black",safeSet(o,"black")),A&&A!=="dark"&&i.indexOf(A)===-1&&(A=null),T(A||t()),k(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(o)||T(E.matches?"dark":"light")}),window.THEMES=i,window.BUILTIN_THEMES=g,window.THEME_COLORS=w,window.THEME_PROPS=s,window.BASE_PROPS=h,window.DERIVED_PROPS=n,window.USER_THEMES_KEY=f,window.applyTheme=T,window.clearCustomProperties=c,window.injectUserThemeStyles=d,window.syncThemesFromServer=k,window.slugifyThemeName=v,window.getActiveTheme=function(){return safeGet(o)||t()},window.deriveExtendedColors=e,window.hexToRgb=m,window.rgbToHex=r,window.blendColors=l})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const w=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!w)return;const m=(.299*+w[0]+.587*+w[1]+.114*+w[2])/255,r=h.querySelector(".totp-logo-dark"),l=h.querySelector(".totp-logo-light");r&&(r.style.display=m>.5?"none":""),l&&(l.style.display=m>.5?"":"none")}function f(){const h=document.getElementById("totp-overlay");if(h){h.classList.add("show"),setTimeout(o,50);const n=h.querySelector(".totp-digits input");n&&setTimeout(()=>n.focus(),100)}}function u(){const h=document.getElementById("totp-overlay");h&&h.classList.remove("show")}const g=document.getElementById("totp-digits");if(g){const h=g.querySelectorAll("input");h.forEach((n,w)=>{n.addEventListener("input",m=>{const r=m.target.value.replace(/\D/g,"");m.target.value=r.slice(0,1),r&&w<h.length-1&&h[w+1].focus();const l=Array.from(h).map(y=>y.value).join("");l.length===6&&i(l)}),n.addEventListener("keydown",m=>{m.key==="Backspace"&&!m.target.value&&w>0&&(h[w-1].focus(),h[w-1].value="")}),n.addEventListener("paste",m=>{m.preventDefault();const r=(m.clipboardData.getData("text")||"").replace(/\D/g,"");r.length>=6&&(h.forEach((l,y)=>{l.value=r[y]||""}),h[5].focus(),i(r.slice(0,6)))})})}async function i(h){const n=document.getElementById("totp-error");n.textContent="Verifying...",n.className="totp-error verifying";try{const m=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();if(m.success){n.textContent="",m.csrfToken&&(csrfToken=m.csrfToken),u();const r=safeSessionGet("totp_redirect");if(r){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=r;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{n.textContent=m.error||"Invalid code",n.className="totp-error";const r=document.querySelectorAll("#totp-digits input");r.forEach(l=>{l.value=""}),r[0]?.focus()}}catch{n.textContent="Connection error",n.className="totp-error"}}const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const h=s.get("return");if(h)try{const n=new URL(h,window.location.origin),w=n.hostname,m=n.origin===window.location.origin,r=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,l=w.endsWith(r)||w===r.substring(1);(m||l)&&safeSessionSet("totp_redirect",h)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=f})(),(function(){const o=new ErrorHandler;injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3> <h3>\u{1F4C2} Browse for Media Folders</h3>
@@ -145,7 +145,7 @@
</button> </button>
</div> </div>
</div> </div>
</div>`);const f=document.getElementById("service-creds-modal");let u=null;const y=["sonarr","radarr","prowlarr","overseerr"],l=["sonarr","radarr"];function r(n){return n.externalUrl||n.url||""}function h(n){const d=document.getElementById("svc-creds-error");d.textContent=n,d.style.display=""}function a(){const n=document.getElementById("svc-creds-error");n.textContent="",n.style.display="none"}window.openServiceCredsModal=async function(n){u=n,a();const d=document.getElementById("svc-creds-title"),g=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),s=document.getElementById("svc-creds-apikey"),p=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");d.textContent=n.name+" Credentials";const c=!!n.isExternal,v=y.includes(n.id)||y.includes(n.appTemplate),i=l.includes(n.id)||l.includes(n.appTemplate);e.style.display=c?"":"none",s.style.display=v?"":"none",t.style.display=i?"":"none",p.style.display=c?"none":"";const E=document.getElementById("svc-quality-select");E.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",c?(g.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${n.name}`):v?g.textContent="API key bypasses the app login screen automatically.":g.textContent="Credentials are injected automatically when accessing this service.",await b(n),f.classList.add("show")};async function b(n){const d=document.getElementById("svc-creds-dot"),g=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let s=!1;if(n.isExternal){try{const c=await(await fetch(`/api/v1/seedhost-creds?serviceId=${n.id}`)).json();c.success?(document.getElementById("svc-seedhost-user").value=c.username||"",c.hasCredentials&&(s=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const c=await(await fetch(`/api/v1/services/${n.id}/credentials`)).json();c.success&&(c.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",s=!0):document.getElementById("svc-apikey-input").value="",c.hasBasicAuth&&!n.isExternal?(document.getElementById("svc-basic-user").value=c.username||"",s=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const p=n.id||n.appTemplate;if(l.includes(p)&&await m(n),s){d.style.background="var(--ok-fg, #74dfc4)",g.style.color="var(--ok-fg, #74dfc4)",g.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${n.id}`);t&&t.classList.add("has-creds")}else d.style.background="var(--muted)",g.style.color="var(--muted)",g.textContent="No credentials stored",e.style.display="none"}async function m(n){const d=document.getElementById("svc-quality-select"),g=document.getElementById("svc-quality-status"),e=n.id||n.appTemplate,s=r(n);if(!s){d.innerHTML='<option value="">-- No service URL --</option>';return}d.innerHTML='<option value="">Loading...</option>',g.textContent="";try{const p=new URLSearchParams({service:e,url:s}),c=await(await fetch(`/api/v1/arr/quality-profiles?${p}`)).json();if(!c.success||!c.profiles?.length){d.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}d.innerHTML="";for(const v of c.profiles){const i=document.createElement("option");i.value=v.id,i.textContent=v.name,d.appendChild(i)}if(c.storedProfileId&&(d.value=String(c.storedProfileId)),!d.value){const v=c.profiles.find(i=>/720/i.test(i.name));v&&(d.value=String(v.id))}!d.value&&c.profiles.length&&(d.value=String(c.profiles[0].id)),g.innerHTML=`<span style="color: var(--ok-fg);">${c.profiles.length} profiles loaded</span>`}catch(p){d.innerHTML='<option value="">-- Failed to load --</option>',g.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${p.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!u)return;const n=u.id||u.appTemplate,d=r(u),e=document.getElementById("svc-apikey-input")?.value.trim(),s=document.getElementById("svc-quality-select"),p=document.getElementById("svc-quality-status");if(!d){p.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){p.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}s.innerHTML='<option value="">Fetching...</option>',p.textContent="";try{const t=new URLSearchParams({service:n,url:d,apiKey:e}),v=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!v.success){s.innerHTML='<option value="">-- Error --</option>',p.innerHTML=`<span style="color: var(--error, #c62828);">${v.error||"Failed to feLine truncated </div>`);const f=document.getElementById("service-creds-modal");let u=null;const g=["sonarr","radarr","prowlarr","overseerr"],i=["sonarr","radarr"];function s(r){return r.externalUrl||r.url||""}function h(r){const l=document.getElementById("svc-creds-error");l.textContent=r,l.style.display=""}function n(){const r=document.getElementById("svc-creds-error");r.textContent="",r.style.display="none"}window.openServiceCredsModal=async function(r){u=r,n();const l=document.getElementById("svc-creds-title"),y=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),a=document.getElementById("svc-creds-apikey"),p=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");l.textContent=r.name+" Credentials";const c=!!r.isExternal,v=g.includes(r.id)||g.includes(r.appTemplate),d=i.includes(r.id)||i.includes(r.appTemplate);e.style.display=c?"":"none",a.style.display=v?"":"none",t.style.display=d?"":"none",p.style.display=c?"none":"";const k=document.getElementById("svc-quality-select");k.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",c?(y.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${r.name}`):v?y.textContent="API key bypasses the app login screen automatically.":y.textContent="Credentials are injected automatically when accessing this service.",await w(r),f.classList.add("show")};async function w(r){const l=document.getElementById("svc-creds-dot"),y=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let a=!1;if(r.isExternal){try{const c=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();c.success?(document.getElementById("svc-seedhost-user").value=c.username||"",c.hasCredentials&&(a=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const c=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();c.success&&(c.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",a=!0):document.getElementById("svc-apikey-input").value="",c.hasBasicAuth&&!r.isExternal?(document.getElementById("svc-basic-user").value=c.username||"",a=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const p=r.id||r.appTemplate;if(i.includes(p)&&await m(r),a){l.style.background="var(--ok-fg, #74dfc4)",y.style.color="var(--ok-fg, #74dfc4)",y.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${r.id}`);t&&t.classList.add("has-creds")}else l.style.background="var(--muted)",y.style.color="var(--muted)",y.textContent="No credentials stored",e.style.display="none"}async function m(r){const l=document.getElementById("svc-quality-select"),y=document.getElementById("svc-quality-status"),e=r.id||r.appTemplate,a=s(r);if(!a){l.innerHTML='<option value="">-- No service URL --</option>';return}l.innerHTML='<option value="">Loading...</option>',y.textContent="";try{const p=new URLSearchParams({service:e,url:a}),c=await(await fetch(`/api/v1/arr/quality-profiles?${p}`)).json();if(!c.success||!c.profiles?.length){l.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}l.innerHTML="";for(const v of c.profiles){const d=document.createElement("option");d.value=v.id,d.textContent=v.name,l.appendChild(d)}if(c.storedProfileId&&(l.value=String(c.storedProfileId)),!l.value){const v=c.profiles.find(d=>/720/i.test(d.name));v&&(l.value=String(v.id))}!l.value&&c.profiles.length&&(l.value=String(c.profiles[0].id)),y.innerHTML=`<span style="color: var(--ok-fg);">${c.profiles.length} profiles loaded</span>`}catch(p){l.innerHTML='<option value="">-- Failed to load --</option>',y.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${p.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!u)return;const r=u.id||u.appTemplate,l=s(u),e=document.getElementById("svc-apikey-input")?.value.trim(),a=document.getElementById("svc-quality-select"),p=document.getElementById("svc-quality-status");if(!l){p.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){p.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}a.innerHTML='<option value="">Fetching...</option>',p.textContent="";try{const t=new URLSearchParams({service:r,url:l,apiKey:e}),v=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!v.success){a.innerHTML='<option value="">-- Error --</option>',p.innerHTML=`<span style="color: var(--error, #c62828);">${v.error||"Failed to feLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3> <h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -240,7 +240,7 @@
</button> </button>
</div> </div>
</div> </div>
</div>`);async function f(){try{const r=await(await fetch("/api/v1/totp/config")).json();if(!r.success)return;const{enabled:h,sessionDuration:a,isSetUp:b}=r.config,m=document.getElementById("totp-status-dot"),n=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),g=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),s=document.getElementById("totp-duration-section"),p=document.getElementById("totp-disable-section");h&&b?(m.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",n.textContent="TOTP is active",n.style.color="var(--ok-fg, #7ef2ff)",g.style.display="none",e.style.display="none",s.style.display="block",p.style.display="block",document.getElementById("totp-duration-select").value=a):(m.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",n.textContent="TOTP is not configured",n.style.color="var(--muted)",g.style.display="block",e.style.display="none",s.style.display="none",p.style.display="none"),y(h&&b,a)}catch(l){console.warn("Failed to load TOTP settings:",l)}}const u={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function y(l,r){const h=document.getElementById("auth-card"),a=document.getElementById("auth-pill"),b=document.getElementById("auth-dot"),m=document.getElementById("auth-status-text");h&&(l?(h.setAttribute("data-status","on"),a.className="badge on",a.textContent="YES",b.className="dot ok at-bl",m.textContent="Session: "+(u[r]||r)):(h.setAttribute("data-status","off"),a.className="badge off",a.textContent="NO",b.className="dot bad at-bl",m.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();r.success&&(document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(l){o.logError("[TOTP] Setup Failed",l,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const l=document.getElementById("totp-import-key").value.trim(),r=document.getElementById("totp-import-error");if(r.textContent="",!l){r.textContent="Paste a Base32 secret key first";return}try{const a=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:l})})).json();a.success?(r.textContent="",document.getElementById("totp-qr-image").src=a.qrCode,document.getElementById("totp-manual-key").textContent=a.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):r.textContent=a.error||a.message||"Import failed"}catch{r.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const l=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(l).then(()=>{const r=document.getElementById("totp-copy-key");r.textContent="\u2705",setTimeout(()=>{r.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const l=document.getElementById("totp-setup-code").value,r=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(l)){r.textContent="Enter a 6-digit code";return}try{const a=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:l})})).json();a.success?(r.textContent="",f()):r.textContent=a.error||"Invalid code"}catch{r.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",l=>{l.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async l=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:l.target.value})}),f()}catch(r){o.logError("[TOTP] Update Session Duration",r,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(awaLine truncated </div>`);async function f(){try{const s=await(await fetch("/api/v1/totp/config")).json();if(!s.success)return;const{enabled:h,sessionDuration:n,isSetUp:w}=s.config,m=document.getElementById("totp-status-dot"),r=document.getElementById("totp-status-text"),l=document.getElementById("totp-status-banner"),y=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),a=document.getElementById("totp-duration-section"),p=document.getElementById("totp-disable-section");h&&w?(m.style.background="var(--ok-fg, #7ef2ff)",l.style.borderColor="var(--ok-fg, #7ef2ff)",l.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",r.textContent="TOTP is active",r.style.color="var(--ok-fg, #7ef2ff)",y.style.display="none",e.style.display="none",a.style.display="block",p.style.display="block",document.getElementById("totp-duration-select").value=n):(m.style.background="var(--muted)",l.style.borderColor="var(--border)",l.style.background="transparent",r.textContent="TOTP is not configured",r.style.color="var(--muted)",y.style.display="block",e.style.display="none",a.style.display="none",p.style.display="none"),g(h&&w,n)}catch(i){console.warn("Failed to load TOTP settings:",i)}}const u={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function g(i,s){const h=document.getElementById("auth-card"),n=document.getElementById("auth-pill"),w=document.getElementById("auth-dot"),m=document.getElementById("auth-status-text");h&&(i?(h.setAttribute("data-status","on"),n.className="badge on",n.textContent="YES",w.className="dot ok at-bl",m.textContent="Session: "+(u[s]||s)):(h.setAttribute("data-status","off"),n.className="badge off",n.textContent="NO",w.className="dot bad at-bl",m.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();s.success&&(document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(i){o.logError("[TOTP] Setup Failed",i,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const i=document.getElementById("totp-import-key").value.trim(),s=document.getElementById("totp-import-error");if(s.textContent="",!i){s.textContent="Paste a Base32 secret key first";return}try{const n=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:i})})).json();n.success?(s.textContent="",document.getElementById("totp-qr-image").src=n.qrCode,document.getElementById("totp-manual-key").textContent=n.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):s.textContent=n.error||n.message||"Import failed"}catch{s.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const i=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(i).then(()=>{const s=document.getElementById("totp-copy-key");s.textContent="\u2705",setTimeout(()=>{s.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const i=document.getElementById("totp-setup-code").value,s=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(i)){s.textContent="Enter a 6-digit code";return}try{const n=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:i})})).json();n.success?(s.textContent="",f()):s.textContent=n.error||"Invalid code"}catch{s.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",i=>{i.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async i=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:i.target.value})}),f()}catch(s){o.logError("[TOTP] Update Session Duration",s,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(awaLine truncated
<div id="token-management-modal" class="weather-modal"> <div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3> <h3>\u{1F511} DNS Credentials</h3>
@@ -281,17 +281,17 @@
</div> </div>
<div class="token-status" id="${v}-token-status"></div> <div class="token-status" id="${v}-token-status"></div>
</div> </div>
`)}function y(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const c=safeGet("dashcaddy-encryption-key");if(c)return safeSessionSet("dashcaddy-encryption-key",c),safeRemove("dashcaddy-encryption-key"),c;const v=new Uint8Array(32);return crypto.getRandomValues(v),t=Array.from(v,i=>i.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const l=y();function r(t,c){if(!t)return"";const v=crypto.getRandomValues(new Uint8Array(8)),i=Array.from(v,L=>L.toString(16).padStart(2,"0")).join(""),E=new TextEncoder().encode(c+i);let T="";for(let L=0;L<t.length;L++){const P=t.charCodeAt(L)^E[L%E.length]^v[L%v.length]^L*31+17&255;T+=String.fromCharCode(P)}return i+":"+btoa(T)}function h(t,c){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(t.substring(17)),S=new TextEncoder().encode(c+T);let B="";for(let $=0;$<P.length;$++){const x=P.charCodeAt($)^S[$%S.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(x)}return B}const i=atob(t);let E="";for(let T=0;T<i.length;T++){const L=i.charCodeAt(T)^c.charCodeAt(T%c.length);E+=String.fromCharCode(L)}return E}catch{return""}}function a(t,c,v){const i=safeGet(`${t}-${c}-${v}-enc`);return h(i,l)}function b(t,c,v,i){const E=`${t}-${c}-${v}-enc`;i?safeSet(E,r(i,l)):safeRemove(E)}function m(t,c){return a(t,c,"token")}function n(t,c){return a(t,c,"username")}function d(t,c,v){b(t,c,"token",v)}function g(t,c,v){b(t,c,"username",v)}function e(){const t={};for(const c of o())t[c]={readonly:{username:n(c,"readonly"),token:m(c,"readonly")},admin:{username:n(c,"admin"),token:m(c,"admin")}};return t}function s(){o().forEach(t=>{["readonly","admin"].forEach(c=>{["token","username"].forEach(v=>{safeRemove(`${t}-${c}-${v}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function p(t){const c=m(t,"readonly"),v=n(t,"readonly"),i=m(t,"admin"),E=n(t,"admin"),T=h(safeGet(`${t}-token-enc`),l),L=h(safeGet(`${t}-username-enc`),l);return{username:E||v||L,token:i||c||T,readonlyToken:c||T,readonlyUsername:v||L,adminToken:i||T,adminUsername:E||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{u();const t=document.getElementById("token-management-modal"),c=e();o().forEach(v=>{const i=c[v];document.getElementById(`${v}-readonly-username`).value=i.readonly.username,document.getElementById(`${v}-readonly-token`).value=i.readonly.token,document.getElementById(`${v}-admin-username`).value=i.admin.username,document.getElementById(`${v}-admin-token`).value=i.admin.token,document.getElementById(`${v}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const c=t.target.closest(".token-toggle");if(c){const v=c.dataset.target,i=document.getElementById(v);i.type==="password"?(i.type="text",c.textContent="\u{1F648}"):(i.type="password",c.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=o();t.forEach(i=>{g(i,"readonly",document.getElementById(`${i}-readonly-username`).value.trim()),d(i,"readonly",document.getElementById(`${i}-readonly-token`).value.trim()),g(i,"admin",document.getElementById(`${i}-admin-username`).value.trim()),d(i,"admin",document.getElementById(`${i}-admin-token`).value.trim())});const c={};let v=!1;if(t.forEach(i=>{const E={},T=document.getElementById(`${i}-readonly-username`).value.trim(),L=document.getElementById(`${i}-readonly-token`).value.trim(),P=document.getElementById(`${i}-admin-username`).value.trim(),S=document.getElementById(`${i}-admin-token`).value.trim();T&&L&&(E.readonly={username:T,password:L},v=!0),P&&S&&(E.admin={username:P,password:S},v=!0),Object.keys(E).length>0&&(c[i]=E)}),v){t.forEach(i=>{c[i]&&(document.getElementById(`${i}-token-status`).textContent="Verifying...",document.getElementById(`${i}-token-status`).className="token-status")});try{const E=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:c})})).json();E.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!c[T]){L.textContent="";return}const P=E.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):E.success?t.forEach(T=>{c[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{c[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(E.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(i){console.erro Line truncated `)}function g(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const c=safeGet("dashcaddy-encryption-key");if(c)return safeSessionSet("dashcaddy-encryption-key",c),safeRemove("dashcaddy-encryption-key"),c;const v=new Uint8Array(32);return crypto.getRandomValues(v),t=Array.from(v,d=>d.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const i=g();function s(t,c){if(!t)return"";const v=crypto.getRandomValues(new Uint8Array(8)),d=Array.from(v,T=>T.toString(16).padStart(2,"0")).join(""),k=new TextEncoder().encode(c+d);let B="";for(let T=0;T<t.length;T++){const A=t.charCodeAt(T)^k[T%k.length]^v[T%v.length]^T*31+17&255;B+=String.fromCharCode(A)}return d+":"+btoa(B)}function h(t,c){if(!t)return"";try{if(t.indexOf(":")===16){const B=t.substring(0,16),T=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(t.substring(17)),E=new TextEncoder().encode(c+B);let L="";for(let $=0;$<A.length;$++){const x=A.charCodeAt($)^E[$%E.length]^T[$%T.length]^$*31+17&255;L+=String.fromCharCode(x)}return L}const d=atob(t);let k="";for(let B=0;B<d.length;B++){const T=d.charCodeAt(B)^c.charCodeAt(B%c.length);k+=String.fromCharCode(T)}return k}catch{return""}}function n(t,c,v){const d=safeGet(`${t}-${c}-${v}-enc`);return h(d,i)}function w(t,c,v,d){const k=`${t}-${c}-${v}-enc`;d?safeSet(k,s(d,i)):safeRemove(k)}function m(t,c){return n(t,c,"token")}function r(t,c){return n(t,c,"username")}function l(t,c,v){w(t,c,"token",v)}function y(t,c,v){w(t,c,"username",v)}function e(){const t={};for(const c of o())t[c]={readonly:{username:r(c,"readonly"),token:m(c,"readonly")},admin:{username:r(c,"admin"),token:m(c,"admin")}};return t}function a(){o().forEach(t=>{["readonly","admin"].forEach(c=>{["token","username"].forEach(v=>{safeRemove(`${t}-${c}-${v}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function p(t){const c=m(t,"readonly"),v=r(t,"readonly"),d=m(t,"admin"),k=r(t,"admin"),B=h(safeGet(`${t}-token-enc`),i),T=h(safeGet(`${t}-username-enc`),i);return{username:k||v||T,token:d||c||B,readonlyToken:c||B,readonlyUsername:v||T,adminToken:d||B,adminUsername:k||T}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{u();const t=document.getElementById("token-management-modal"),c=e();o().forEach(v=>{const d=c[v];document.getElementById(`${v}-readonly-username`).value=d.readonly.username,document.getElementById(`${v}-readonly-token`).value=d.readonly.token,document.getElementById(`${v}-admin-username`).value=d.admin.username,document.getElementById(`${v}-admin-token`).value=d.admin.token,document.getElementById(`${v}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const c=t.target.closest(".token-toggle");if(c){const v=c.dataset.target,d=document.getElementById(v);d.type==="password"?(d.type="text",c.textContent="\u{1F648}"):(d.type="password",c.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=o();t.forEach(d=>{y(d,"readonly",document.getElementById(`${d}-readonly-username`).value.trim()),l(d,"readonly",document.getElementById(`${d}-readonly-token`).value.trim()),y(d,"admin",document.getElementById(`${d}-admin-username`).value.trim()),l(d,"admin",document.getElementById(`${d}-admin-token`).value.trim())});const c={};let v=!1;if(t.forEach(d=>{const k={},B=document.getElementById(`${d}-readonly-username`).value.trim(),T=document.getElementById(`${d}-readonly-token`).value.trim(),A=document.getElementById(`${d}-admin-username`).value.trim(),E=document.getElementById(`${d}-admin-token`).value.trim();B&&T&&(k.readonly={username:B,password:T},v=!0),A&&E&&(k.admin={username:A,password:E},v=!0),Object.keys(k).length>0&&(c[d]=k)}),v){t.forEach(d=>{c[d]&&(document.getElementById(`${d}-token-status`).textContent="Verifying...",document.getElementById(`${d}-token-status`).className="token-status")});try{const k=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:c})})).json();k.results?t.forEach(B=>{const T=document.getElementById(`${B}-token-status`);if(!c[B]){T.textContent="";return}const A=k.results[B];A?.success?(T.textContent="\u2713 Verified & saved",T.className="token-status success"):A?.partial?(T.textContent="\u2713 "+A.partial,T.className="token-status success"):(T.textContent="\u2717 "+(A?.error||"Login failed"),T.className="token-status error")}):k.success?t.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):t.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(k.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(d){console.erro Line truncated
Current: ${g.currentVersion} Current: ${y.currentVersion}
New: ${g.updateVersion} New: ${y.updateVersion}
`+(g.updateTitle?`${g.updateTitle} `+(y.updateTitle?`${y.updateTitle}
`:"")+`The DNS server will restart during the update. `:"")+`The DNS server will restart during the update.
Proceed?`)){m.textContent=n,m.disabled=!1,m.title="Update DNS server";return}m.textContent="\u{1F504}",m.title="Updating...";const p=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(b)}`,{method:"POST"})).json();if(!p.success)throw new Error(p.error||"Update failed");if(p.manualUpdateRequired){m.textContent="\u2B06\uFE0F",m.title=`Update available: ${p.newVersion}`;const t=p.downloadLink?` Proceed?`)){m.textContent=r,m.disabled=!1,m.title="Update DNS server";return}m.textContent="\u{1F504}",m.title="Updating...";const p=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(w)}`,{method:"POST"})).json();if(!p.success)throw new Error(p.error||"Update failed");if(p.manualUpdateRequired){m.textContent="\u2B06\uFE0F",m.title=`Update available: ${p.newVersion}`;const t=p.downloadLink?`
Download: ${p.downloadLink}`:"",c=p.instructionsLink?` Download: ${p.downloadLink}`:"",c=p.instructionsLink?`
Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} update requires manual installation. Current: ${p.previousVersion} \u2192 ${p.newVersion}. Please update manually on the host machine.`,"warning",8e3),m.disabled=!1;return}m.textContent="\u2705",m.title="Updated successfully!",showNotification(`${a.toUpperCase()} updated successfully! ${p.previousVersion} \u2192 ${p.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{m.textContent=n,m.disabled=!1,m.title="Update DNS server",window.refreshAll()},1e4)}catch(d){console.error("DNS update error:",d),m.textContent="\u274C",m.title="Update failed",showNotification(`Failed to update ${a.toUpperCase()}: ${d.message}`,"error"),setTimeout(()=>{m.textContent=n,m.disabled=!1,m.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",a=>{const b=a.target.closest('[id$="-update"]');if(!b)return;const m=b.id.replace("-update","");SITE.dnsServers[m]&&f(m,SITE.dnsServers[m]?.ip)}),injectModal("dns-settings-modal",` Instructions: ${p.instructionsLink}`:"";showNotification(`${n.toUpperCase()} update requires manual installation. Current: ${p.previousVersion} \u2192 ${p.newVersion}. Please update manually on the host machine.`,"warning",8e3),m.disabled=!1;return}m.textContent="\u2705",m.title="Updated successfully!",showNotification(`${n.toUpperCase()} updated successfully! ${p.previousVersion} \u2192 ${p.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{m.textContent=r,m.disabled=!1,m.title="Update DNS server",window.refreshAll()},1e4)}catch(l){console.error("DNS update error:",l),m.textContent="\u274C",m.title="Update failed",showNotification(`Failed to update ${n.toUpperCase()}: ${l.message}`,"error"),setTimeout(()=>{m.textContent=r,m.disabled=!1,m.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",n=>{const w=n.target.closest('[id$="-update"]');if(!w)return;const m=w.id.replace("-update","");SITE.dnsServers[m]&&f(m,SITE.dnsServers[m]?.ip)}),injectModal("dns-settings-modal",`
<div id="dns-settings-modal" class="weather-modal"> <div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3> <h3 id="dns-settings-title">DNS Settings</h3>
@@ -318,7 +318,7 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button> <button id="dns-settings-save" class="btn-accent">Save</button>
</div> </div>
</div> </div>
</div>`);let u=null;function y(a){u=a;const b=SITE.dnsServers[a]||{},m=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(b.name||a).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=b.ip||"",document.getElementById("dns-edit-port").value=b.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=b.name||"",m.classList.add("show")}async function l(){if(!u)return;const a=document.getElementById("dns-edit-ip").value.trim(),b=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,m=document.getElementById("dns-edit-name").value.trim();if(!a){showNotification("Server IP is required","warning");return}const n={dnsServers:{}};n.dnsServers[u]={ip:a,port:String(b)},m&&(n.dnsServers[u].name=m);try{const g=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();g.success?(SITE.dnsServers[u]=n.dnsServers[u],showNotification(`${u.toUpperCase()} settings saved`,"success"),h(),window.refreshAll()):showNotification(g.error||"Failed to save settings","error")}catch(d){showNotification("Failed to save: "+d.message,"error")}}async function r(){if(u&&confirm(`Remove ${u.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const b=await(await secureFetch("/api/v1/config")).json();b.dnsServers&&delete b.dnsServers[u];const n=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:b.dnsServers||{}})})).json();if(n.success){delete SITE.dnsServers[u];const d=document.querySelector(`.top [data-app="${u}"]`);d&&d.remove(),showNotification(`${u.toUpperCase()} removed from dashboard`,"success"),h()}else showNotification(n.error||"Failed to remove","error")}catch(a){showNotification("Failed to remove: "+a.message,"error")}}function h(){closeModal("dns-settings-modal"),u=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",h),document.getElementById("dns-settings-save")?.addEventListener("click",l),document.getElementById("dns-settings-delete")?.addEventListener("click",r),document.getElementById("dns-settings-modal")?.addEventListener("click",a=>{a.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",a=>{const b=a.target.closest('[id$="-settings"]');if(!b)return;const m=b.id.replace("-settings","");SITE.dnsServers[m]&&(a.stopPropagation(),y(m))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",` </div>`);let u=null;function g(n){u=n;const w=SITE.dnsServers[n]||{},m=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(w.name||n).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=w.ip||"",document.getElementById("dns-edit-port").value=w.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=w.name||"",m.classList.add("show")}async function i(){if(!u)return;const n=document.getElementById("dns-edit-ip").value.trim(),w=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,m=document.getElementById("dns-edit-name").value.trim();if(!n){showNotification("Server IP is required","warning");return}const r={dnsServers:{}};r.dnsServers[u]={ip:n,port:String(w)},m&&(r.dnsServers[u].name=m);try{const y=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)})).json();y.success?(SITE.dnsServers[u]=r.dnsServers[u],showNotification(`${u.toUpperCase()} settings saved`,"success"),h(),window.refreshAll()):showNotification(y.error||"Failed to save settings","error")}catch(l){showNotification("Failed to save: "+l.message,"error")}}async function s(){if(u&&confirm(`Remove ${u.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const w=await(await secureFetch("/api/v1/config")).json();w.dnsServers&&delete w.dnsServers[u];const r=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:w.dnsServers||{}})})).json();if(r.success){delete SITE.dnsServers[u];const l=document.querySelector(`.top [data-app="${u}"]`);l&&l.remove(),showNotification(`${u.toUpperCase()} removed from dashboard`,"success"),h()}else showNotification(r.error||"Failed to remove","error")}catch(n){showNotification("Failed to remove: "+n.message,"error")}}function h(){closeModal("dns-settings-modal"),u=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",h),document.getElementById("dns-settings-save")?.addEventListener("click",i),document.getElementById("dns-settings-delete")?.addEventListener("click",s),document.getElementById("dns-settings-modal")?.addEventListener("click",n=>{n.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",n=>{const w=n.target.closest('[id$="-settings"]');if(!w)return;const m=w.id.replace("-settings","");SITE.dnsServers[m]&&(n.stopPropagation(),g(m))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
<div id="logs-modal" class="logs-modal"> <div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header"> <div class="logs-header">
@@ -342,66 +342,66 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
</div> </div>
</div> </div>
</div> </div>
</div>`);let o=null,f=null,u=!1,y=null,l=null,r=!1,h=null,a=null,b=!1,m=null,n=!1;async function d(x,w=25){try{const C=getDnsServerAddr(x),k=await fetch(`/api/v1/dns/logs?server=${C}&limit=${w}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,server:I.server}:{error:I.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(C){return console.error("DNS logs fetch failed:",C),{error:C.message}}}function g(x){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[x]||"var(--fg)"}function e(x){const w=document.createElement("div");if(w.className="log-entry",w.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",x.parsed===!1)return w.style.gridTemplateColumns="1fr",w.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(x.raw)}</span>`,w;const C=g(x.rcode),k=x.rcode==="Refused"||x.rcode==="REFUSED";return w.innerHTML=` </div>`);let o=null,f=null,u=!1,g=null,i=null,s=!1,h=null,n=null,w=!1,m=null,r=!1;async function l(x,b=25){try{const I=getDnsServerAddr(x),S=await fetch(`/api/v1/dns/logs?server=${I}&limit=${b}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,server:C.server}:{error:C.error||"Failed to fetch logs"}}else return S.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${S.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function y(x){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[x]||"var(--fg)"}function e(x){const b=document.createElement("div");if(b.className="log-entry",b.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",x.parsed===!1)return b.style.gridTemplateColumns="1fr",b.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(x.raw)}</span>`,b;const I=y(x.rcode),S=x.rcode==="Refused"||x.rcode==="REFUSED";return b.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.timestamp)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(x.client)}">${escapeHtml(x.client)}</span> <span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(x.client)}">${escapeHtml(x.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${k?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(x.domain)}">${escapeHtml(x.domain)}</span> <span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${S?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(x.domain)}">${escapeHtml(x.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.type)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.type)}</span>
<span style="color: ${C}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(x.rcode)}</span> <span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(x.rcode)}</span>
`,w}async function s(){if(b){await B();return}if(r){await T();return}if(u||!o)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await d(o,x);if(C.error){w.innerHTML=` `,b}async function a(){if(w){await L();return}if(s){await B();return}if(u||!o)return;const x=parseInt(document.getElementById("log-lines").value),b=document.getElementById("logs-content");try{const I=await l(o,x);if(I.error){b.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(C.error)}</div> <div>${escapeHtml(I.error)}</div>
</div>`;return}w.innerHTML=` </div>`;return}b.innerHTML=`
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span>Time</span> <span>Time</span>
<span>Client</span> <span>Client</span>
<span>Domain</span> <span>Domain</span>
<span>Type</span> <span>Type</span>
<span>Status</span> <span>Status</span>
</div>`,C.logs&&C.logs.length>0?C.logs.forEach(k=>{const I=e(k);w.appendChild(I)}):w.innerHTML+=` </div>`,I.logs&&I.logs.length>0?I.logs.forEach(S=>{const C=e(S);b.appendChild(C)}):b.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet No DNS queries logged yet
</div>`}catch(C){w.innerHTML=` </div>`}catch(I){b.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(C.message)} Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function p(x){o=x,u=!1,r=!1;const w=document.getElementById("logs-modal"),C=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),I=document.getElementById("logs-stream");C.textContent=`${x.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),I&&(I.style.display="none"),w.classList.add("show"),s(),f=setInterval(s,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),f&&(clearInterval(f),f=null),v(),o=null,r=!1,y=null,l=null,b=!1,h=null,a=null,u=!1}function c(x){m&&v();const w=document.getElementById("logs-stream"),C=document.getElementById("logs-pause"),k=document.getElementById("logs-content");f&&(clearInterval(f),f=null);try{m=new EventSource(`/api/v1/logs/stream/${x}`),n=!0,w.classList.add("active"),w.textContent="\u{1F534} Live",w.title="Streaming - click to stop",C.style.display="none";const I=document.getElementById("logs-title");I.textContent.includes("\u{1F534}")||(I.innerHTML=I.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),m.onmessage=A=>{try{const O=JSON.parse(A.data);if(O.error){console.error("Stream error:",O.error),v();return}const D=document.createElement("div");D.className="log-entry",D.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(O.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",F=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(D.innerHTML=` </div>`}}function p(x){o=x,u=!1,s=!1;const b=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),C=document.getElementById("logs-stream");I.textContent=`${x.toUpperCase()} DNS Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),C&&(C.style.display="none"),b.classList.add("show"),a(),f=setInterval(a,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),f&&(clearInterval(f),f=null),v(),o=null,s=!1,g=null,i=null,w=!1,h=null,n=null,u=!1}function c(x){m&&v();const b=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),S=document.getElementById("logs-content");f&&(clearInterval(f),f=null);try{m=new EventSource(`/api/v1/logs/stream/${x}`),r=!0,b.classList.add("active"),b.textContent="\u{1F534} Live",b.title="Streaming - click to stop",I.style.display="none";const C=document.getElementById("logs-title");C.textContent.includes("\u{1F534}")||(C.innerHTML=C.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),m.onmessage=P=>{try{const D=JSON.parse(P.data);if(D.error){console.error("Stream error:",D.error),v();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${U}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
<div style="flex-shrink: 0;">${F}</div> <div style="flex-shrink: 0;">${M}</div>
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(O.text)}</div> <div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
`,k.appendChild(D),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},m.onerror=A=>{console.error("EventSource error:",A),v()}}catch(I){console.error("Failed to start streaming:",I),v()}}function v(){m&&(m.close(),m=null),n=!1;const x=document.getElementById("logs-stream"),w=document.getElementById("logs-pause"),C=document.getElementById("logs-title");x&&(x.classList.remove("active"),x.textContent="\u{1F4E1} Live",x.title="Enable real-time streaming"),w&&(w.style.display=""),C&&(C.textContent=C.textContent.replace(" \u{1F534}","")),r&&y&&!f&&(f=setInterval(T,DC.POLL.LOGS))}async function i(x,w=100){try{const C=`/api/v1/logs/container/${x}?tail=${w}&timestamps=true`,k=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,containerName:I.containerName,containerId:I.containerId}:{error:I.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(C){return console.error("Container logs fetch failed:",C),{error:C.message}}}function E(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=x.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=x.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return w.innerHTML=` `,S.appendChild(O),S.scrollTop=S.scrollHeight;S.children.length>500;)S.removeChild(S.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},m.onerror=P=>{console.error("EventSource error:",P),v()}}catch(C){console.error("Failed to start streaming:",C),v()}}function v(){m&&(m.close(),m=null),r=!1;const x=document.getElementById("logs-stream"),b=document.getElementById("logs-pause"),I=document.getElementById("logs-title");x&&(x.classList.remove("active"),x.textContent="\u{1F4E1} Live",x.title="Enable real-time streaming"),b&&(b.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),s&&g&&!f&&(f=setInterval(B,DC.POLL.LOGS))}async function d(x,b=100){try{const I=`/api/v1/logs/container/${x}?tail=${b}&timestamps=true`,S=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,containerName:C.containerName,containerId:C.containerId}:{error:C.error||"Failed to fetch container logs"}}else return{error:`HTTP ${S.status}: ${S.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function k(x){const b=document.createElement("div");b.className="log-entry",b.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.stream==="stderr"?"var(--bad-fg)":"var(--fg)",S=x.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return b.innerHTML=`
<div style="flex-shrink: 0;">${k}</div> <div style="flex-shrink: 0;">${S}</div>
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(x.text)}</div> <div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(x.text)}</div>
`,w}async function T(){if(u||!y||!r)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await i(y,x);if(C.error){w.innerHTML=` `,b}async function B(){if(u||!g||!s)return;const x=parseInt(document.getElementById("log-lines").value),b=document.getElementById("logs-content");try{const I=await d(g,x);if(I.error){b.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(C.error)}</div> <div>${escapeHtml(I.error)}</div>
</div>`;return}w.innerHTML=` </div>`;return}b.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex-shrink: 0; width: 80px;">Stream</span> <span style="flex-shrink: 0; width: 80px;">Stream</span>
<span style="flex: 1;">Log Output</span> <span style="flex: 1;">Log Output</span>
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(k=>{const I=E(k);w.appendChild(I)}),w.scrollTop=w.scrollHeight):w.innerHTML+=` </div>`,I.logs&&I.logs.length>0?(I.logs.forEach(S=>{const C=k(S);b.appendChild(C)}),b.scrollTop=b.scrollHeight):b.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container No logs available for this container
</div>`}catch(C){w.innerHTML=` </div>`}catch(I){b.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(C.message)} Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function L(x,w){y=x,l=w,r=!0,b=!1,u=!1,v();const C=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Container Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display=""),C.classList.add("show"),T(),f=setInterval(T,DC.POLL.LOGS)}async function P(x,w=100){try{const C=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${w}`,k=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,logPath:I.logPath,totalLines:I.totalLines}:{error:I.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(C){return console.error("File logs fetch failed:",C),{error:C.message}}}function S(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=x.text;let k="INFO",I="var(--fg)";C.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",I="var(--bad-fg)"):C.match(/WARN|WARNING/i)?(k="WARN",I="#f39c12"):C.match(/DEBUG/i)&&(k="DEBUG",I="var(--muted)");const O=`<span style="background: ${I==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${I}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${k}</span>`;return w.innerHTML=` </div>`}}function T(x,b){g=x,i=b,s=!0,w=!1,u=!1,v();const I=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");S.textContent=`\u{1F4CB} ${b} - Container Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display=""),I.classList.add("show"),B(),f=setInterval(B,DC.POLL.LOGS)}async function A(x,b=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${b}`,S=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,logPath:C.logPath,totalLines:C.totalLines}:{error:C.error||"Failed to fetch file logs"}}else return{error:(await S.json().catch(()=>({}))).error||`HTTP ${S.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function E(x){const b=document.createElement("div");b.className="log-entry",b.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.text;let S="INFO",C="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(S="ERROR",C="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(S="WARN",C="#f39c12"):I.match(/DEBUG/i)&&(S="DEBUG",C="var(--muted)");const D=`<span style="background: ${C==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${C}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${S}</span>`;return b.innerHTML=`
<div style="flex-shrink: 0;">${O}</div> <div style="flex-shrink: 0;">${D}</div>
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(C)}</div> <div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
`,w}async function B(){if(u||!h||!b)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await P(h,x);if(C.error){w.innerHTML=` `,b}async function L(){if(u||!h||!w)return;const x=parseInt(document.getElementById("log-lines").value),b=document.getElementById("logs-content");try{const I=await A(h,x);if(I.error){b.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(C.error)}</div> <div>${escapeHtml(I.error)}</div>
</div>`;return}w.innerHTML=` </div>`;return}b.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex: 1;">Log Output (${C.count} of ${C.totalLines} lines)</span> <span style="flex: 1;">Log Output (${I.count} of ${I.totalLines} lines)</span>
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(k=>{const I=S(k);w.appendChild(I)}),w.scrollTop=w.scrollHeight):w.innerHTML+=` </div>`,I.logs&&I.logs.length>0?(I.logs.forEach(S=>{const C=E(S);b.appendChild(C)}),b.scrollTop=b.scrollHeight):b.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file No logs available in this file
</div>`}catch(C){w.innerHTML=` </div>`}catch(I){b.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(C.message)} Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function $(x,w){h=x,a=w,b=!0,r=!1,u=!1;const C=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Application Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display="none"),C.classList.add("show"),B(),f=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const w=x.target.closest('[id$="-logs"]');if(!w)return;const C=w.id.replace("-logs","");SITE.dnsServers[C]&&p(C)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{u=!u;const x=document.getElementById("logs-pause");u?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),s())}),document.getElementById("log-lines")?.addEventListener("change",()=>{u||s()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!r||!y||(n?v():c(y))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=p})(),(function(){injectModal("service-edit-modal",` </div>`}}function $(x,b){h=x,n=b,w=!0,s=!1,u=!1;const I=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");S.textContent=`\u{1F4CB} ${b} - Application Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display="none"),I.classList.add("show"),L(),f=setInterval(L,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const b=x.target.closest('[id$="-logs"]');if(!b)return;const I=b.id.replace("-logs","");SITE.dnsServers[I]&&p(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{u=!u;const x=document.getElementById("logs-pause");u?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),a())}),document.getElementById("log-lines")?.addEventListener("change",()=>{u||a()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!s||!g||(r?v():c(g))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=T,window.openFileLogsModal=$,window.openLogsModal=p})(),(function(){injectModal("service-edit-modal",`
<div id="service-edit-modal" class="weather-modal"> <div id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3> <h3 id="service-edit-title">Edit Service</h3>
@@ -480,16 +480,6 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
Enter a URL or upload an image file (PNG, JPG, SVG) Enter a URL or upload an image file (PNG, JPG, SVG)
</div> </div>
</div> </div>
<!-- Category -->
<div>
<label for="edit-service-category" class="form-label-accent-sm">
Category
</label>
<select id="edit-service-category" data-role="service-category" class="form-input-md">
<option value="">\u2014 No category \u2014</option>
</select>
</div>
</div> </div>
<div class="weather-modal-buttons" style="margin-top: 24px;"> <div class="weather-modal-buttons" style="margin-top: 24px;">
@@ -643,15 +633,6 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
Reload Caddy after adding Reload Caddy after adding
</label> </label>
<!-- Category -->
<div>
<label for="service-category-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
<select id="service-category-input" data-role="service-category" style="width: 100%;">
<option value="">\u2014 No category \u2014</option>
</select>
<div style="font-size: 0.7rem; color: var(--muted); margin-top: 3px;">Group services on the dashboard by purpose (Media, Productivity, etc.)</div>
</div>
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" /> <hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
<div class="grid-2col"> <div class="grid-2col">
@@ -739,14 +720,6 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
Follow Redirects Follow Redirects
</label> </label>
<!-- Category (external) -->
<div>
<label for="external-service-category" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
<select id="external-service-category" data-role="service-category" style="width: 100%;">
<option value="">\u2014 No category \u2014</option>
</select>
</div>
</div> </div>
</details> </details>
</div> </div>
@@ -757,44 +730,44 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button> <button id="add-service-create" class="btn-accent">Create Service</button>
</div> </div>
</div> </div>
</div>`)})(),(function(){async function o(r){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(r)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const a=await h.json();if(a.status==="success"){const b=document.getElementById("existing-ca-select");return b.innerHTML="",a.data.cas.length===0?b.innerHTML='<option value="">No CAs found in Caddyfile</option>':(b.innerHTML='<option value="">Select existing CA...</option>',a.data.cas.forEach(m=>{const n=document.createElement("option");typeof m=="object"?(n.value=m.id,n.textContent=m.displayName||m.name):(n.value=m,n.textContent=m),b.appendChild(n)})),a.data.cas}else throw new Error(a.message)}catch(h){console.error("Error loading CAs:",h);const a=document.getElementById("existing-ca-select");return a.innerHTML='<option value="">Error loading CAs</option>',[]}}function f(r){const{subdomain:h,port:a,ip:b,sslType:m,caName:n,existingCa:d,enableAuth:g,enableCors:e,customHeaders:s,upstreamPath:p,healthCheck:t,timeout:c,tailscaleOnly:v}=r;let i=`${buildDomain(h)} { </div>`)})(),(function(){async function o(s){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(s)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const n=await h.json();if(n.status==="success"){const w=document.getElementById("existing-ca-select");return w.innerHTML="",n.data.cas.length===0?w.innerHTML='<option value="">No CAs found in Caddyfile</option>':(w.innerHTML='<option value="">Select existing CA...</option>',n.data.cas.forEach(m=>{const r=document.createElement("option");typeof m=="object"?(r.value=m.id,r.textContent=m.displayName||m.name):(r.value=m,r.textContent=m),w.appendChild(r)})),n.data.cas}else throw new Error(n.message)}catch(h){console.error("Error loading CAs:",h);const n=document.getElementById("existing-ca-select");return n.innerHTML='<option value="">Error loading CAs</option>',[]}}function f(s){const{subdomain:h,port:n,ip:w,sslType:m,caName:r,existingCa:l,enableAuth:y,enableCors:e,customHeaders:a,upstreamPath:p,healthCheck:t,timeout:c,tailscaleOnly:v}=s;let d=`${buildDomain(h)} {
`;switch(v&&(i+=` @blocked not remote_ip 100.64.0.0/10 `;switch(v&&(d+=` @blocked not remote_ip 100.64.0.0/10
`,i+=` respond @blocked "Access denied. Tailscale connection required." 403 `,d+=` respond @blocked "Access denied. Tailscale connection required." 403
`),m){case"letsencrypt":break;case"caddy-managed":i+=` tls internal `),m){case"letsencrypt":break;case"caddy-managed":d+=` tls internal
`;break;case"existing-ca":d&&(i+=` tls { `;break;case"existing-ca":l&&(d+=` tls {
ca ${d} ca ${l}
} }
`);break;case"custom-ca":n&&(i+=` tls { `);break;case"custom-ca":r&&(d+=` tls {
ca ${n} ca ${r}
} }
`);break}if(g&&(i+=` basicauth { `);break}if(y&&(d+=` basicauth {
admin $2a$14$hashed_password_here admin $2a$14$hashed_password_here
} }
`),e&&(i+=` header { `),e&&(d+=` header {
`,i+=` Access-Control-Allow-Origin "*" `,d+=` Access-Control-Allow-Origin "*"
`,i+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" `,d+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,i+=` Access-Control-Allow-Headers "Content-Type, Authorization" `,d+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,i+=` } `,d+=` }
`),s)try{const E=JSON.parse(s);i+=` header { `),a)try{const k=JSON.parse(a);d+=` header {
`,Object.entries(E).forEach(([T,L])=>{i+=` ${T} "${L}" `,Object.entries(k).forEach(([B,T])=>{d+=` ${B} "${T}"
`}),i+=` } `}),d+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(i+=` health_uri ${t} `}catch{console.warn("Invalid JSON in custom headers")}return t&&(d+=` health_uri ${t}
`),i+=` reverse_proxy ${b}:${a} { `),d+=` reverse_proxy ${w}:${n} {
`,p&&p!=="/"&&(i+=` rewrite ${p} `,p&&p!=="/"&&(d+=` rewrite ${p}
`),c&&c!==30&&(i+=` transport http { `),c&&c!==30&&(d+=` transport http {
`,i+=` dial_timeout ${c}s `,d+=` dial_timeout ${c}s
`,i+=` response_header_timeout ${c}s `,d+=` response_header_timeout ${c}s
`,i+=` } `,d+=` }
`),i+=` } `),d+=` }
`,i+=`} `,d+=`}
`,i}async function u(r,h,a=DC.DEFAULTS.TTL){const b=window.getToken(getPrimaryDnsId(),"admin");if(!b)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const m=buildDomain(r),n=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:m,ip:h,ttl:a,token:b,server:SITE.dnsIp})});if(!n.ok){const g=await n.text();throw new Error(`DNS API Error: ${n.status} - ${g}`)}const d=await n.json();if(!d.success)throw new Error(`DNS Error: ${d.error||"Unknown error"}`);return d}async function y(r){const h={id:r.subdomain,name:r.name,logo:r.logo||`/assets/${r.subdomain}.png`};r.category&&(h.category=r.category),r.containerId&&(h.containerId=r.containerId);try{const a=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!a.ok){const b=await a.json();throw new Error(b.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),h}catch(a){throw console.error("Failed to add service to config:",a),a}}async function l(r){const h=document.getElementById("service-subdomain-input").value.trim(),a=document.getElementById("service-ip-input").value.trim()||"localhost",b=document.getElementById("service-port-input").value.trim()||"80",m=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(h),upstream:`${a}:${b}`,config:r})}),n=await m.json();if(!m.ok||!n.success)throw new Error(n.error||`Caddy API Error: ${m.status}`);return n}window.loadExistingCAs=o,window.generateCaddyConfig=f,window.createDnsRecord=u,window.addServiceToConfig=y,window.addToCaddyfile=l})(),(function(){let o=null;function f(a){o=a;const b=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${a.name}`,document.getElementById("edit-service-name").value=a.name,document.getElementById("edit-service-url-display").textContent=a.url||buildServiceUrl(a.id),document.getElementById("edit-service-logo-preview").src=a.logo||`/assets/${a.id}.png`,document.getElementById("edit-subdomain").value=a.id,document.getElementById("edit-port").value=a.port||"",document.getElementById("edit-ip").value=a.ip||"localhost",document.getElementById("edit-tailscale-only").checked=a.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=a.logo||"";const m=document.getElementById("edit-service-category");m&&(m.dataset.current=a.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),b.classList.add("show")}function u(){closeModal("service-edit-modal"),o=null}async function y(){if(!o)return;const a=document.getElementById("edit-subdomain").value.trim().toLowerCase(),b=document.getElementById("edit-service-name").value.trim(),m=document.getElementById("edit-port").value.trim(),n=document.getElementById("edit-ip").value.trim()||"localhost",d=document.getElementById("edit-tailscale-only").checked,g=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!a){showNotification("Subdomain is required","warning");return}const s=o.id,p=[];if(a!==s&&p.push("subdomain"),b&&b!==o.name&&p.push("name"),m&&m!==String(o.port)&&p.push("port"),n!==o.ip&&p.push("ip"),d!==(o.tailscaleOnly||!1)&&p.push("tailscale"),g&&g!==o.logo&&p.push("logo"),e!==(o.category||"")&&p.push("category"),p.length===0){u();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const v=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:s,newSubdomain:a,name:b||o.name,port:m||o.port,ip:n,tailscaleOnly:d,logo:g||void 0,category:e})})).json();if(!v.success)throw new Error(v.error||"Failed to update service");const i=window.APPS.findIndex(E=>E.id===s);i!==-1&&(window.APPS[i]={...window.APPS[i],id:a,name:b||window.APPS[i].name,port:m||window.APPS[i].port,ip:n,tailscaleOnly:d,logo:g||window.APPS[i].logo,category:e||void 0}),u(),window.buildGrid(),window.refreshAll()}catch(c){console.error("Error saving service changes:",c),showNotification(`Error saving changes: ${c.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async a=>{const b=a.target.files[0];if(!b)return;if(!b.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const m=new FileReader;m.onload=async n=>{const d=n.target.result;if(document.getElementById("edit-service-logo-preview").src=d,document.getElementById("edit-logo-url").value=d,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:d})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val Line truncated `,d}async function u(s,h,n=DC.DEFAULTS.TTL){const w=window.getToken(getPrimaryDnsId(),"admin");if(!w)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const m=buildDomain(s),r=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:m,ip:h,ttl:n,token:w,server:SITE.dnsIp})});if(!r.ok){const y=await r.text();throw new Error(`DNS API Error: ${r.status} - ${y}`)}const l=await r.json();if(!l.success)throw new Error(`DNS Error: ${l.error||"Unknown error"}`);return l}async function g(s){const h={id:s.subdomain,name:s.name,logo:s.logo||`/assets/${s.subdomain}.png`};try{const n=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!n.ok){const w=await n.json();throw new Error(w.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),h}catch(n){throw console.error("Failed to add service to config:",n),n}}async function i(s){const h=document.getElementById("service-subdomain-input").value.trim(),n=document.getElementById("service-ip-input").value.trim()||"localhost",w=document.getElementById("service-port-input").value.trim()||"80",m=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(h),upstream:`${n}:${w}`,config:s})}),r=await m.json();if(!m.ok||!r.success)throw new Error(r.error||`Caddy API Error: ${m.status}`);return r}window.loadExistingCAs=o,window.generateCaddyConfig=f,window.createDnsRecord=u,window.addServiceToConfig=g,window.addToCaddyfile=i})(),(function(){let o=null;function f(n){o=n;const w=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${n.name}`,document.getElementById("edit-service-name").value=n.name,document.getElementById("edit-service-url-display").textContent=n.url||buildServiceUrl(n.id),document.getElementById("edit-service-logo-preview").src=n.logo||`/assets/${n.id}.png`,document.getElementById("edit-subdomain").value=n.id,document.getElementById("edit-port").value=n.port||"",document.getElementById("edit-ip").value=n.ip||"localhost",document.getElementById("edit-tailscale-only").checked=n.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=n.logo||"",w.classList.add("show")}function u(){closeModal("service-edit-modal"),o=null}async function g(){if(!o)return;const n=document.getElementById("edit-subdomain").value.trim().toLowerCase(),w=document.getElementById("edit-service-name").value.trim(),m=document.getElementById("edit-port").value.trim(),r=document.getElementById("edit-ip").value.trim()||"localhost",l=document.getElementById("edit-tailscale-only").checked,y=document.getElementById("edit-logo-url").value.trim();if(!n){showNotification("Subdomain is required","warning");return}const e=o.id,a=[];if(n!==e&&a.push("subdomain"),w&&w!==o.name&&a.push("name"),m&&m!==String(o.port)&&a.push("port"),r!==o.ip&&a.push("ip"),l!==(o.tailscaleOnly||!1)&&a.push("tailscale"),y&&y!==o.logo&&a.push("logo"),a.length===0){u();return}const p=document.getElementById("service-edit-save");p.textContent="Saving...",p.disabled=!0;try{const c=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:n,name:w||o.name,port:m||o.port,ip:r,tailscaleOnly:l,logo:y||void 0})})).json();if(!c.success)throw new Error(c.error||"Failed to update service");const v=window.APPS.findIndex(d=>d.id===e);v!==-1&&(window.APPS[v]={...window.APPS[v],id:n,name:w||window.APPS[v].name,port:m||window.APPS[v].port,ip:r,tailscaleOnly:l,logo:y||window.APPS[v].logo}),u(),window.buildGrid(),window.refreshAll()}catch(t){console.error("Error saving service changes:",t),showNotification(`Error saving changes: ${t.message}`,"error")}finally{p.textContent="Save Changes",p.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async n=>{const w=n.target.files[0];if(!w)return;if(!w.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const m=new FileReader;m.onload=async r=>{const l=r.target.result;if(document.getElementById("edit-service-logo-preview").src=l,document.getElementById("edit-logo-url").value=l,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:l})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},m.readAsDataURL(w)}),document.getElementById("service-edit-cancel")?.addEventListener("click",u),document.getElementById("service-edit-save")?.addEventListener("click",g),document.getElementById("service-edit-modal")?.addEventListener("click",n=>{n.target.id==="service-edit-modal"&&u()});function i(n,w,m){return new Promise(r=>{const l=document.getElementById("delete-ser Line truncated
This will: This will:
1. Pull the latest image 1. Pull the latest image
2. Stop the container 2. Stop the container
3. Recreate with same settings 3. Recreate with same settings
The service will be briefly unavailable.`))try{n&&(n.textContent="\u{1F504}",n.disabled=!0,n.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${a}/update`,{method:"POST"})).json();if(e.success){const s=window.APPS.find(p=>p.id===m);s&&e.newContainerId&&(s.containerId=e.newContainerId),n&&(n.textContent="\u2705",n.title="Updated successfully!",setTimeout(()=>{n.textContent=d,n.disabled=!1,n.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${b} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(g){console.error("Update error:",g),n&&(n.textContent="\u274C",n.title="Update failed",setTimeout(()=>{n.textContent=d,n.disabled=!1,n.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${b}: ${g.message}`,"error")}}async function h(a,b){const m=window.APPS.find(i=>i.id===a),n=m?buildDomain(m.id):null,d=m?.containerId,g=await l(b||a,d,m?.containerId);if(g===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(g&&d)try{const i=new URLSearchParams({containerId:m.containerId,subdomain:m.id,ip:m.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(m.id)}?${i.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(i){console.error("App removal error:",i)}else if(g&&n){try{const i=m?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(n)}&type=A&ipAddress=${encodeURIComponent(i)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(i){e.dns=i.message}try{const E=await(await secureFetch(`/api/v1/site/${encodeURIComponent(n)}`,{method:"DELETE"})).json();e.caddy=E.success||E.error&&E.error.includes("not found")?"removed":E.error||"failed"}catch(i){e.caddy=i.message}}const s=window.APPS.findIndex(i=>i.id===a);s>-1&&(window.APPS.splice(s,1),e.dashboard=!0);try{const i=safeGetJSON("custom-apps",[]),E=i.findIndex(T=>T.id===a);E>-1&&(i.splice(E,1),safeSet("custom-apps",JSON.stringify(i)))}catch{}try{const E=await(await secureFetch(`/api/v1/services/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.service=E.success?"removed":E.error||"failed"}catch(i){e.service=i.message}window.buildGrid(),window.refreshAll();let p=!1,t=[];e.dashboard||(p=!0,t.push("\u2717 Failed to remove from dashboard"));const c=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],v=i=>!i||c.some(E=>i.toLowerCase().includes(E.toLowerCase()));e.container&&!v(e.container)&&(p=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!v(e.dns)&&(p=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!v(e.caddy)&&(p=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!v(e.service)&&(p=!0,t.push(`\u26A0 Service File: ${e.service}`)),p&&showNotification(`Error deleting "${b||a}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=f,window.showDeleteModal=l,window.updateContainer=r,window.deleteService=h})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function f(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function u(){const e=document.getElementById("service-subdomain-input").value||"subdomain",s=document.getElementById("service-ip-input").value||y.lan||"localhost",p=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,c=document.getElementById("ca-name-input").value||"sami-ca",v=document.getElementById("existing-ca-select").value,i=document.getElementById("enable-auth").checked,E=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,S=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${s}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:p,ip:s,sslType:t,caName:c,existingCa:v,enableAuth:i,enableCors:E,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:S},w=window.generateCaddyConfig(x),C=document.getElementById("caddy-config-preview");C&&(C.value=w)}const y={localhost:"127.0.0.1",lan:"",tailscale:""};async function l(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const c=await t.json();c.lan&&(y.lan=c.lan),c.tailscale&&(y.tailscale=c.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),s=document.getElementById("quick-ip-tailscale");e&&(y.lan?(e.dataset.ip=y.lan,e.textContent=`LAN (${y.lan})`,e.title=`LAN IP: ${y.lan}`):e Line truncated The service will be briefly unavailable.`))try{r&&(r.textContent="\u{1F504}",r.disabled=!0,r.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${n}/update`,{method:"POST"})).json();if(e.success){const a=window.APPS.find(p=>p.id===m);a&&e.newContainerId&&(a.containerId=e.newContainerId),r&&(r.textContent="\u2705",r.title="Updated successfully!",setTimeout(()=>{r.textContent=l,r.disabled=!1,r.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${w} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(y){console.error("Update error:",y),r&&(r.textContent="\u274C",r.title="Update failed",setTimeout(()=>{r.textContent=l,r.disabled=!1,r.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${w}: ${y.message}`,"error")}}async function h(n,w){const m=window.APPS.find(d=>d.id===n),r=m?buildDomain(m.id):null,l=m?.containerId,y=await i(w||n,l,m?.containerId);if(y===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(y&&l)try{const d=new URLSearchParams({containerId:m.containerId,subdomain:m.id,ip:m.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(m.id)}?${d.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(d){console.error("App removal error:",d)}else if(y&&r){try{const d=m?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(r)}&type=A&ipAddress=${encodeURIComponent(d)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(d){e.dns=d.message}try{const k=await(await secureFetch(`/api/v1/site/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.caddy=k.success||k.error&&k.error.includes("not found")?"removed":k.error||"failed"}catch(d){e.caddy=d.message}}const a=window.APPS.findIndex(d=>d.id===n);a>-1&&(window.APPS.splice(a,1),e.dashboard=!0);try{const d=safeGetJSON("custom-apps",[]),k=d.findIndex(B=>B.id===n);k>-1&&(d.splice(k,1),safeSet("custom-apps",JSON.stringify(d)))}catch{}try{const k=await(await secureFetch(`/api/v1/services/${encodeURIComponent(n)}`,{method:"DELETE"})).json();e.service=k.success?"removed":k.error||"failed"}catch(d){e.service=d.message}window.buildGrid(),window.refreshAll();let p=!1,t=[];e.dashboard||(p=!0,t.push("\u2717 Failed to remove from dashboard"));const c=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],v=d=>!d||c.some(k=>d.toLowerCase().includes(k.toLowerCase()));e.container&&!v(e.container)&&(p=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!v(e.dns)&&(p=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!v(e.caddy)&&(p=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!v(e.service)&&(p=!0,t.push(`\u26A0 Service File: ${e.service}`)),p&&showNotification(`Error deleting "${w||n}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=f,window.showDeleteModal=i,window.updateContainer=s,window.deleteService=h})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function f(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function u(){const e=document.getElementById("service-subdomain-input").value||"subdomain",a=document.getElementById("service-ip-input").value||g.lan||"localhost",p=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,c=document.getElementById("ca-name-input").value||"sami-ca",v=document.getElementById("existing-ca-select").value,d=document.getElementById("enable-auth").checked,k=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,T=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,L=document.getElementById("dns-preview");L&&(L.textContent=`${buildDomain(e)} \u2192 ${a}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:p,ip:a,sslType:t,caName:c,existingCa:v,enableAuth:d,enableCors:k,customHeaders:B,upstreamPath:T,healthCheck:A,timeout:E},b=window.generateCaddyConfig(x),I=document.getElementById("caddy-config-preview");I&&(I.value=b)}const g={localhost:"127.0.0.1",lan:"",tailscale:""};async function i(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const c=await t.json();c.lan&&(g.lan=c.lan),c.tailscale&&(g.tailscale=c.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),a=document.getElementById("quick-ip-tailscale");e&&(g.lan?(e.dataset.ip=g.lan,e.textContent=`LAN (${g.lan})`,e.title=`LAN IP: ${g.lan}`):e Line truncated
<span style="color: #4caf50;">\u2713 Connected</span> <span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${E.self?.hostname} (${E.self?.ip})</span> <span style="color: var(--muted); margin-left: 6px;">${k.self?.hostname} (${k.self?.ip})</span>
`,v.disabled=!1):E.installed?(c.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',v.disabled=!0):(c.innerHTML='<span style="color: var(--muted);">Not available</span>',v.disabled=!0)}catch{c.innerHTML='<span style="color: var(--muted);">Could not check</span>',v.disabled=!0}v.checked=!1,u()}function a(){const e=document.getElementById("service-type-local"),s=document.getElementById("service-type-external"),p=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),c=document.getElementById("tab-local"),v=document.getElementById("tab-external");function i(){e.checked?(p.style.display="grid",t.style.display="none",c&&(c.style.background="var(--accent)",c.style.color="var(--bg)"),v&&(v.style.background="transparent",v.style.color="var(--muted)")):(p.style.display="none",t.style.display="block",v&&(v.style.background="var(--accent)",v.style.color="var(--bg)"),c&&(c.style.background="transparent",c.style.color="var(--muted)"))}e?.addEventListener("change",i),s?.addEventListener("change",i)}function b(){const e=document.getElementById("service-name-input"),s=document.getElementById("service-subdomain-input"),p=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=o(e.value);!t&&s&&(s.value=L),p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),u()}),s?.addEventListener("input",()=>{t=s.value!==o(e?.value||"");const L=s.value.trim()||o(e?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),u()});const c=document.getElementById("external-service-name"),v=document.getElementById("external-service-subdomain"),i=document.getElementById("external-subdomain-preview"),E=document.getElementById("external-domain-preview");let T=!1;c?.addEventListener("input",()=>{const L=o(c.value);!T&&v&&(v.value=L);const P=v?.value||L;i&&(i.textContent=P?`\u2192 ${buildDomain(P)}`:""),E&&(E.textContent=P?buildDomain(P):"")}),v?.addEventListener("input",()=>{T=v.value!==o(c?.value||"");const L=v.value.trim()||o(c?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),E&&(E.textContent=L?buildDomain(L):"")})}async function m(){const e=document.getElementById("external-service-name").value.trim(),s=document.getElementById("external-service-url").value.trim(),p=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),c=document.getElementById("external-service-icon").value.trim(),v=document.getElementById("external-create-dns").checked,i=document.getElementById("external-create-caddy").checked,E=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!s){showNotification("Please fill in Name and External URL","warning");return}if(!p){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!s.startsWith("http://")&&!s.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const S=buildDomain(p);try{const B={dns:null,caddy:null,dashboard:!1};if(v)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:S,ip:E,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(I){B.dns=I.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(i)try{const k={subdomain:p,externalUrl:s,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:p,name:e,url:`https://${S}`,externalUrl:s,logo:t||c||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const x=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(k=>!x.includes(k.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),n();const C=[`External service "${e}" added!`];v&&C.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),i&&C.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),C.push(`Access at: https://${S}`),showNotification(C.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated `,v.disabled=!1):k.installed?(c.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',v.disabled=!0):(c.innerHTML='<span style="color: var(--muted);">Not available</span>',v.disabled=!0)}catch{c.innerHTML='<span style="color: var(--muted);">Could not check</span>',v.disabled=!0}v.checked=!1,u()}function n(){const e=document.getElementById("service-type-local"),a=document.getElementById("service-type-external"),p=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),c=document.getElementById("tab-local"),v=document.getElementById("tab-external");function d(){e.checked?(p.style.display="grid",t.style.display="none",c&&(c.style.background="var(--accent)",c.style.color="var(--bg)"),v&&(v.style.background="transparent",v.style.color="var(--muted)")):(p.style.display="none",t.style.display="block",v&&(v.style.background="var(--accent)",v.style.color="var(--bg)"),c&&(c.style.background="transparent",c.style.color="var(--muted)"))}e?.addEventListener("change",d),a?.addEventListener("change",d)}function w(){const e=document.getElementById("service-name-input"),a=document.getElementById("service-subdomain-input"),p=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const T=o(e.value);!t&&a&&(a.value=T),p&&(p.textContent=T?`\u2192 ${buildDomain(T)}`:""),u()}),a?.addEventListener("input",()=>{t=a.value!==o(e?.value||"");const T=a.value.trim()||o(e?.value||"");p&&(p.textContent=T?`\u2192 ${buildDomain(T)}`:""),u()});const c=document.getElementById("external-service-name"),v=document.getElementById("external-service-subdomain"),d=document.getElementById("external-subdomain-preview"),k=document.getElementById("external-domain-preview");let B=!1;c?.addEventListener("input",()=>{const T=o(c.value);!B&&v&&(v.value=T);const A=v?.value||T;d&&(d.textContent=A?`\u2192 ${buildDomain(A)}`:""),k&&(k.textContent=A?buildDomain(A):"")}),v?.addEventListener("input",()=>{B=v.value!==o(c?.value||"");const T=v.value.trim()||o(c?.value||"");d&&(d.textContent=T?`\u2192 ${buildDomain(T)}`:""),k&&(k.textContent=T?buildDomain(T):"")})}async function m(){const e=document.getElementById("external-service-name").value.trim(),a=document.getElementById("external-service-url").value.trim(),p=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),c=document.getElementById("external-service-icon").value.trim(),v=document.getElementById("external-create-dns").checked,d=document.getElementById("external-create-caddy").checked,k=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,T=document.getElementById("external-follow-redirects").checked;if(!e||!a){showNotification("Please fill in Name and External URL","warning");return}if(!p){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!a.startsWith("http://")&&!a.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(p);try{const E={dns:null,caddy:null,dashboard:!1};if(v)if(window.getToken(getPrimaryDnsId(),"admin"))try{const C=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:A,ip:k,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();E.dns=C.success?"created":C.error||"failed"}catch(S){E.dns=S.message}else E.dns="no admin token (configure in \u{1F511} Tokens)";if(d)try{const I={subdomain:p,externalUrl:a,preserveHost:B,followRedirects:T,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},C=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(I)})).json();E.caddy=C.success?"created":C.error||"failed"}catch(I){E.caddy=I.message}const L={id:p,name:e,url:`https://${A}`,externalUrl:a,logo:t||c||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(L),E.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(I=>!$.includes(I.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(I){console.warn("Failed to save to services.json:",I)}window.buildGrid(),window.refreshAll(),r();const b=[`External service "${e}" added!`];v&&b.push(`DNS: ${E.dns==="created"?"\u2713":"\u26A0 "+E.dns}`),d&&b.push(`Caddy: ${E.caddy==="created"?"\u2713":"\u26A0 "+E.caddy}`),b.push(`Access at: https://${A}`),showNotification(b.join(" | "),"success",6e3)}catch(E){console.error("Failed to create external service:",E),showNotification(`Failed to create external service: ${E.message}`,"error")}}function r(){closeModal("a Line truncated
+234 -309
View File
@@ -90,44 +90,44 @@
<button id="logo-save" class="btn-accent">Save</button> <button id="logo-save" class="btn-accent">Save</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),P=document.getElementById("logo-preview-light"),w=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),O=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),b=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),B=document.getElementById("dashboard-title"),S=DC.NAME;let T=null,j=null,H=null,R="left",x=S;N?.addEventListener("change",()=>{N.checked?(O.style.display="none",z.style.display="",T=null,j=null):(O.style.display="flex",z.style.display="none",H=null)});function D(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const o=new FileReader;o.onload=a=>e(a.target.result),o.readAsDataURL(n)}A?.addEventListener("change",n=>{D(n.target.files[0],e=>{T=e,E.src=e,w.textContent="New dark logo ready to save"})}),v?.addEventListener("change",n=>{D(n.target.files[0],e=>{j=e,P.src=e,w.textContent="New light logo ready to save"})}),L?.addEventListener("change",n=>{D(n.target.files[0],e=>{H=e,E.src=e,P.src=e,w.textContent="New logo ready to save (both themes)"})});function g(n){k.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){x=n||S,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function f(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(b.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(M.src=e.customLogoLight,P.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(b.src=e.customLogo,M.src=e.customLogo,E.src=e.customLogo,P.src=e.customLogo),e.isDefault||(w.textContent="Using custom logo"),e.position&&(R=e.position,g(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",()=>{R=n.dataset.pos,g(R)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,j=null,H=null,A&&(A.value=""),v&&(v.value=""),L&&(L.value=""),N&&(N.checked=!1),O.style.display="flex",z.style.display="none",E.src=b.src,P.src=M.src;const n=b.src.includes("custom-logo")||M.src.includes("custom-logo");w.textContent=n?"Using custom logo":"Using default logos",g(R),B.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=B.value.trim()||S,e={position:R,dashboardTitle:n};N?.checked&&H?(e.dataDark=H,e.dataLight=H):(T&&(e.dataDark=T),j&&(e.dataLight=j));const o=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(o.ok){const a=await o.json(),r="?t="+Date.now();a.pathDark&&(b.src=a.pathDark+r,E.src=a.pathDark+r),a.pathLight&&(M.src=a.pathLight+r,P.src=a.pathLight+r),g(R),u(n),h.classList.remove("show")}else{const a=await o.json();showNotification("Failed to save: "+a.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? </div>`);const b=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),N=document.getElementById("logo-preview-light"),S=document.getElementById("logo-status"),T=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),L=document.getElementById("logo-single-upload"),H=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),I=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),$=document.querySelector(".top-row"),C=document.getElementById("dashboard-title"),R=DC.NAME;let M=null,j=null,B=null,A="left",w=R;T?.addEventListener("change",()=>{T.checked?(P.style.display="none",L.style.display="",M=null,j=null):(P.style.display="flex",L.style.display="none",B=null)});function z(a,e){if(!a||!a.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=t=>e(t.target.result),n.readAsDataURL(a)}H?.addEventListener("change",a=>{z(a.target.files[0],e=>{M=e,E.src=e,S.textContent="New dark logo ready to save"})}),g?.addEventListener("change",a=>{z(a.target.files[0],e=>{j=e,N.src=e,S.textContent="New light logo ready to save"})}),I?.addEventListener("change",a=>{z(a.target.files[0],e=>{B=e,E.src=e,N.src=e,S.textContent="New logo ready to save (both themes)"})});function f(a){$.setAttribute("data-logo-pos",a),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===a?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===a?"white":"var(--fg)"})}function p(a){w=a||R,document.title=w;const e=document.querySelector(".dashboard-title");e&&(e.textContent=w)}async function y(){try{const a=await fetch("/api/v1/logo");if(a.ok){const e=await a.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,N.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,x.src=e.customLogo,E.src=e.customLogo,N.src=e.customLogo),e.isDefault||(S.textContent="Using custom logo"),e.position&&(A=e.position,f(e.position)),e.dashboardTitle&&p(e.dashboardTitle)}}catch(a){console.warn("Could not load custom logo:",a.message)}}document.querySelectorAll(".logo-pos-btn").forEach(a=>{a.addEventListener("click",()=>{A=a.dataset.pos,f(A)})}),document.getElementById("brand")?.addEventListener("click",()=>{M=null,j=null,B=null,H&&(H.value=""),g&&(g.value=""),I&&(I.value=""),T&&(T.checked=!1),P.style.display="flex",L.style.display="none",E.src=k.src,N.src=x.src;const a=k.src.includes("custom-logo")||x.src.includes("custom-logo");S.textContent=a?"Using custom logo":"Using default logos",f(A),C.value=w,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const a=C.value.trim()||R,e={position:A,dashboardTitle:a};T?.checked&&B?(e.dataDark=B,e.dataLight=B):(M&&(e.dataDark=M),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const t=await n.json(),i="?t="+Date.now();t.pathDark&&(k.src=t.pathDark+i,E.src=t.pathDark+i),t.pathLight&&(x.src=t.pathLight+i,N.src=t.pathLight+i),f(A),p(a),b.classList.remove("show")}else{const t=await n.json();showNotification("Failed to save: "+t.error,"error")}}catch(a){showNotification("Error saving: "+a.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&&(b.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",P.src="/assets/dashcaddy-logo-light.png",w.textContent="Using default logos",T=null,j=null,H=null,B.value=S,u(S),R="left",g("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const o=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),r=document.getElementById("favicon-status");o&&(o.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),r&&(r.textContent="Using DashCaddy favicon"),i=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const m=document.getElementById("favicon-preview"),p=document.getElementById("favicon-status"),d=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let i=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function $(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const e=await n.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),m.src=e.customFavicon+"?t="+Date.now(),p.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}d?.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"),d.value="";return}const o=new FileReader;o.onload=a=>{i=a.target.result,m.src=i,p.textContent="New favicon ready to save"},o.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(i)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:i})});if(n.ok){const e=await n.json();c.href=e.path+"?t="+Date.now(),m.src=e.path+"?t="+Date.now(),p.textContent="Using custom favicon",i=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),$(),f();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{h.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const o=await fetch("/api/v1/config");o.ok&&(e=(await o.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const o=await fetch("/api/v1/config");if(!o.ok)return;const a=await o.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(o){console.warn("Failed to save timezone:",o.message)}}))})(),window.populateTimezoneSelect=function(h,E){const P=Intl.supportedValuesOf("timeZone"),w=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of P){const O=document.createElement("option");O.value=N,O.textContent=N.replace(/_/g," "),N===w&&(O.selected=!0),h.appendChild(O)}},(function(){let h="homelab",E=null;async function P(){try{const D=await fetch("/api/v1/config");if(D.ok&&(E=await D.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}P();const w=document.getElementById("setup-timezone");w&&window.populateTimezoneSelect(w);function N(x){document.querySelectorAll(".setup-step").forEach(g=>{g.style.display="none"});const D=document.getElementById(x);D&&(D.style.display="block")}function O(){const x=document.getElementById("setup-summary-content");if(!x)return;let D='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const u=document.getElementById("setup-tld")?.value?.trim()||".home",f=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=` This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(k.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",N.src="/assets/dashcaddy-logo-light.png",S.textContent="Using default logos",M=null,j=null,B=null,C.value=R,p(R),A="left",f("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),s=null}}catch(a){showNotification("Error resetting branding: "+a.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const v=document.getElementById("favicon-preview"),m=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let s=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function h(){try{const a=await fetch("/api/v1/favicon");if(a.ok){const e=await a.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),v.src=e.customFavicon+"?t="+Date.now(),m.textContent="Using custom favicon")}}catch(a){console.warn("Could not load custom favicon:",a.message)}}r?.addEventListener("change",a=>{const e=a.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const n=new FileReader;n.onload=t=>{s=t.target.result,v.src=s,m.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(s)try{const a=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:s})});if(a.ok){const e=await a.json();c.href=e.path+"?t="+Date.now(),v.src=e.path+"?t="+Date.now(),m.textContent="Using custom favicon",s=null}else{const e=await a.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(a){showNotification("Error saving favicon: "+a.message,"error")}}),h(),y();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{b.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const t=await n.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(n){console.warn("Failed to save timezone:",n.message)}}))})(),window.populateTimezoneSelect=function(b,E){const N=Intl.supportedValuesOf("timeZone"),S=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const T of N){const P=document.createElement("option");P.value=T,P.textContent=T.replace(/_/g," "),T===S&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",E=null;async function N(){try{const z=await fetch("/api/v1/config");if(z.ok&&(E=await z.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(z){console.warn("Could not fetch server config, checking localStorage fallback:",z.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}N();const S=document.getElementById("setup-timezone");S&&window.populateTimezoneSelect(S);function T(w){document.querySelectorAll(".setup-step").forEach(f=>{f.style.display="none"});const z=document.getElementById(w);z&&(z.style.display="block")}function P(){const w=document.getElementById("setup-summary-content");if(!w)return;let z='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const p=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",v=document.getElementById("setup-dns-ip")?.value?.trim()||"",m=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;z+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${u}</div> <div><strong>TLD:</strong> ${p}</div>
<div><strong>Certificate Authority:</strong> ${f}</div> <div><strong>Certificate Authority:</strong> ${y}</div>
<div><strong>DNS Server:</strong> ${m}:${p}</div> <div><strong>DNS Server:</strong> ${v}:${m}</div>
<div><strong>Example URLs:</strong> https://uptime${u}, https://nextcloud${u}</div> <div><strong>Example URLs:</strong> https://uptime${p}, https://nextcloud${p}</div>
</div> </div>
</div> </div>
`}else if(h==="simple"){const u=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=` `}else if(b==="simple"){const p=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";z+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Access Method:</strong> IP:Port only</div> <div><strong>Access Method:</strong> IP:Port only</div>
<div><strong>Default IP:</strong> ${u}</div> <div><strong>Default IP:</strong> ${p}</div>
<div><strong>SSL:</strong> None (HTTP only)</div> <div><strong>SSL:</strong> None (HTTP only)</div>
<div><strong>Example URLs:</strong> http://${u}:8080, http://${u}:3000</div> <div><strong>Example URLs:</strong> http://${p}:8080, http://${p}:3000</div>
</div> </div>
</div> </div>
`}else if(h==="public"){const u=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"",m=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=m==="subdirectory"?`https://${u}/sonarr, https://${u}/grafana`:`https://sonarr.${u}, https://grafana.${u}`;D+=` `}else if(b==="public"){const p=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",v=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m=v==="subdirectory"?`https://${p}/sonarr, https://${p}/grafana`:`https://sonarr.${p}, https://grafana.${p}`;z+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${u}</div> <div><strong>Domain:</strong> ${p}</div>
<div><strong>SSL:</strong> Let's Encrypt</div> <div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${f}</div> <div><strong>Email:</strong> ${y}</div>
<div><strong>Routing:</strong> ${m==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div> <div><strong>Routing:</strong> ${v==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${p}</div> <div><strong>Example URLs:</strong> ${m}</div>
</div> </div>
</div> </div>
`}const g=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=` `}const f=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";z+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);"> <div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${g.replace(/_/g," ")}</div> <div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${f.replace(/_/g," ")}</div>
</div> </div>
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};h==="homelab"?(x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",x.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()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-pub Line truncated `,z+="</div>",w.innerHTML=z,T("setup-step-summary")}async function L(w){try{const z=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(w)});return z.ok?(await z.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${z.status}`),{function:"saveConfigToServer"}),!1)}catch(z){return errorHandler.logError("[SetupWizard] Save Config",z,{function:"saveConfigToServer"}),!1}}async function H(){const w={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};b==="homelab"?(w.tld=document.getElementById("setup-tld")?.value?.trim()||".home",w.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",w.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()||""},w.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):b==="simple"?(w.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",w.defaults={dnsType:"none",sslType:"none",targetIP:w.defaultIP}):b==="public"&&(w.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",w.email=document.getElementById("setup-public-email")?.value?.trim()||"",w.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",w.defaults={dnsType:w.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const z=await L(w);safeSet("dashcaddy-config",JSON.stringify(w)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const f=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",p=z?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${f}. Settings saved to: ${p}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(w){w.preventDefault();const z=document.querySelector('input[name="config-type"]:checked');z&&(b=z.value),T(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const I=document.getElementById("setup-skip");I&&(I.onclick=async function(w){w.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await L({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(w){const z=w.target.value||".home",f=document.getElementById("tld-preview"),p=document.getElementById("tld-preview-2");f&&(f.textContent=z),p&&(p.textContent=z)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(w){w.preventDefault(),T("setup-step-1")});const $=document.getElementById("setup-homelab-next");$&&($.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-tld")?.value?.trim()||"",f=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!z||!z.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!f){showNotification("Please enter a Certificate Authority name","warning");return}if(!p){showNotification("Please enter your DNS server IP address","warning");return}P()});const C=document.getElementById("setup-simple-back");C&&(C.onclick=function(w){w.preventDefault(),T("setup-step-1")});const R=document.getElementById("setup-simple-next");R&&(R.onclick=function(w){w.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(w){w.onchange=function(){var z=document.getElementById("dns-requirement-note");z&&(z.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const M=document.getElementById("setup-public-back");M&&(M.onclick=function(w){w.preventDefault(),T("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"";if(!z){showNotification("Please enter your domain name","warning");return}if(!f||!f.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const B=document.getElementById("setup-summary-back");B&&(B.onclick=function(w){w.preventDefault(),b==="homelab"?T("setup-step-homelab"):b==="simple"?T("setup-step-simple"):b==="public"&&T("setup-step-pub Line truncated
<div class="app-selector-content"> <div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2> <h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div> <div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button> </button>
</div> </div>
</div> </div>
</div>`);const E="custom-apps";let P=null,w=null;const N=document.getElementById("app-selector-modal"),O=document.getElementById("app-selector-grid");async function z(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return P=c.templates,w=c.categories,!0}catch(d){h.logError("[AppSelector] Fetch Templates",d,{function:"fetchApiTemplates"})}return!1}async function A(d){try{return await(await fetch(`/api/v1/apps/ports/${d}/check`)).json()}catch(c){return h.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(d){try{const i=await(await fetch(`/api/v1/apps/ports/${d}/suggest`)).json();if(i.success)return i.suggestedPort}catch(c){h.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return d}async function L(){if(O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!P&&!await z()){O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}O.innerHTML="";const d={};for(const[i,$]of Object.entries(P)){const y=$.category||"Other";d[y]||(d[y]=[]),d[y].push({id:i,...$})}const c=w?Object.keys(w):Object.keys(d).sort();for(const i of c){const $=d[i];if(!$||$.length===0)continue;$.sort((e,o)=>(o.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const n=w?.[i]||{};y.innerHTML=`${escapeHtml(n.icon||"")} ${escapeHtml(i)}`,n.color&&(y.style.borderBottomColor=n.color),O.appendChild(y),$.forEach(e=>{const o=document.createElement("div");o.className="app-option";const a=e.isDashboardWidget,r=a&&safeGet("widget-"+e.id+"-enabled")!=="false",t=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${r?"#2ecc7130":"#e74c3c30"}; color: ${r?"#2ecc71":"#e74c3c"}; font-weight: 600;">${r?"ON":"OFF"}</div>`:"",s=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";o.innerHTML=` </div>`);const E="custom-apps";let N=null,S=null;const T=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function L(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return N=c.templates,S=c.categories,!0}catch(r){b.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function H(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function g(r){try{const s=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(s.success)return s.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function I(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!N&&!await L()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[s,h]of Object.entries(N)){const u=h.category||"Other";r[u]||(r[u]=[]),r[u].push({id:s,...h})}const c=S?Object.keys(S):Object.keys(r).sort();for(const s of c){const h=r[s];if(!h||h.length===0)continue;h.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const a=S?.[s]||{};u.innerHTML=`${escapeHtml(a.icon||"")} ${escapeHtml(s)}`,a.color&&(u.style.borderBottomColor=a.color),P.appendChild(u),h.forEach(e=>{const n=document.createElement("div");n.className="app-option";const t=e.isDashboardWidget,i=t&&safeGet("widget-"+e.id+"-enabled")!=="false",o=t?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",d=!t&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div> <div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div> <div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div> <div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${t}${s} ${o}${d}
`,a?o.onclick=()=>b(e,o):o.onclick=()=>M(e),O.appendChild(o)})}window.renderRecipeCards&&await window.renderRecipeCards(O)}function b(d,c){const i="widget-"+d.id+"-enabled",y=!(safeGet(i)!=="false");safeSet(i,String(y));const n=d.widgetSelector;if(n){const o=document.querySelector(n);o&&(o.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${d.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function M(d){const c=document.getElementById("app-deploy-modal"),i=document.getElementById("app-deploy-title"),$=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),n=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),o=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:d.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${d.name} container: `,t?n.onclick=()=>k(e,n):n.onclick=()=>x(e),P.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function k(r,c){const s="widget-"+r.id+"-enabled",u=!(safeGet(s)!=="false");safeSet(s,String(u));const a=r.widgetSelector;if(a){const n=document.querySelector(a);n&&(n.style.display=u?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=u?"ON":"OFF",e.style.background=u?"#2ecc7130":"#e74c3c30",e.style.color=u?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function x(r){const c=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),h=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),a=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),t=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:r.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${r.name} container:
Container: ${V.name} Container: ${V.name}
Status: ${V.status} Status: ${V.status}
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
Would you like to use this existing container? Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container. Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(d._useExisting=!0,d._existingContainer=V)}}catch{}i.textContent=`Deploy ${d.name}`;const r=d.subdomain||d.id.replace(/-/g,"");$.value=r;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const _=d.subpathSupport||"strip";_==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+d.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(t.style.display="block",t.innerHTML='<span style="color: var(--muted);">&#9432; '+d.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),C=document.querySelector(`input[name="dns-type"][value="${s}"]`),I=document.querySelector(`input[name="ssl-type"][value="${l}"]`);C?C.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,I?I.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,n.value=SITE.defaults.targetIP||"localhost",o.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(d.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const le=te.includes(ee.hostPath);Z.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);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const de=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=de.indexOf(ee.hostPath);pe>=0?(de.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(de.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=de.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(d.id==="plex"||d.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",d.docker?.volumes?.length){const _=d.mediaMount?.containerPath,W=d.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=` Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}s.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");h.value=i;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=r.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">&#9432; '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const d=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),D=document.querySelector(`input[name="dns-type"][value="${d}"]`),O=document.querySelector(`input[name="ssl-type"][value="${l}"]`);D?D.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,O?O.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,a.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=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;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),J&&!J.dataset.moved&&(U.appendChild(J),J.dataset.moved="1")}const G=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){G.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const _=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){_.style.display="block",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=W.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),W.value=le.join(", ")},J.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else G.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const _=r.mediaMount?.containerPath,J=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));J.length>0?(ne.style.display="block",J.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}" <input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" /> style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span> <span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button> <button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const le=Z.querySelector(".vol-host-path");openFolderBrowser(le)}})):ne.style.display="none"}else ne.style.display="none";const se=d.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await A(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await v(se);Y.innerHTML=` `,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const J=await H(_);if(J.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span> <span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(J.conflict?.usedBy||"unknown")}</span>
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=` `;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const J=await(await fetch("/api/v1/tailscale/status")).json();J.success&&J.installed&&J.connected?t.innerHTML=`
<span style="color: #4caf50;">Connected</span> <span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span> <span style="color: var(--muted); margin-left: 8px;">${J.self?.hostname} (${J.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span> <span style="color: var(--muted); margin-left: 8px;">| ${J.deviceCount} devices</span>
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',o.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=$.value||"subdomain",W=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}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||d.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${n.value}:${te}`}y.textContent=K}$.oninput=ae,n.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(d)}async function k(d){const c=d.appTemplate,i=safeGetJSON(E,[]),$=c._useExisting&&c._existingContainer,y=i.find(n=>n.id===d.subdomain);if(!(y&&!$&&!confirm(`An app with subdomain "${d.subdomain}" already exists. Redeploy?`))){if(y){const n=i.indexOf(y);i.splice(n,1),safeSet(E,JSON.stringify(i))}if($)d.port=c._existingContainer.primaryPort;else{const n=d.port||c.defaultPort||8080;showNotification(`Checking port ${n} availability...`,"info",0);const e=await A(n);if(!e.available){const o=await v(c.defaultPort||8080);if(confirm(`Port ${n} is already in use by ${e.conflict?.usedBy||"another container"}. `:J.installed?t.innerHTML='<span style="color: #ff9800;">Not connected</span>':(t.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{t.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=h.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}/${_}`;else if(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${a.value}:${te}`}u.textContent=K}h.oninput=ae,a.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),T.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function $(r){const c=r.appTemplate,s=safeGetJSON(E,[]),h=c._useExisting&&c._existingContainer,u=s.find(a=>a.id===r.subdomain);if(!(u&&!h&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(u){const a=s.indexOf(u);s.splice(a,1),safeSet(E,JSON.stringify(s))}if(h)r.port=c._existingContainer.primaryPort;else{const a=r.port||c.defaultPort||8080;showNotification(`Checking port ${a} availability...`,"info",0);const e=await H(a);if(!e.available){const n=await g(c.defaultPort||8080);if(confirm(`Port ${a} is already in use by ${e.conflict?.usedBy||"another container"}.
Would you like to use port ${o} instead?`))d.port=o;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification($?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const n={appId:c.id,config:{subdomain:d.subdomain,ip:d.ip,createDns:d.dnsType==="private",port:d.port||c.defaultPort||null,sslType:d.sslType,dnsType:d.dnsType,tailscaleOnly:d.tailscaleOnly||!1,mediaPath:d.mediaPath||null,plexClaimToken:d.plexClaimToken||null,customVolumes:d.customVolumes||null}};$&&(n.config.useExisting=!0,n.config.existingContainerId=c._existingContainer.id,n.config.existingPort=c._existingContainer.primaryPort,!d.port&&c._existingContainer.primaryPort&&(n.config.port=c._existingContainer.primaryPort));const o=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();if(o.success){const a={id:d.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:o.containerId,url:o.url,ip:d.ip,appTemplate:c.id,tailscaleOnly:d.tailscaleOnly||!1};i.push(a),safeSet(E,JSON.stringify(i)),window.APPS&&!window.APPS.some(t=>t.id===c.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let r=o.usedExisting?`${c.name} configured with existing container! Would you like to use port ${n} instead?`))r.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(h?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const a={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};h&&(a.config.useExisting=!0,a.config.existingContainerId=c._existingContainer.id,a.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(a.config.port=c._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();if(n.success){const t={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:n.containerId,url:n.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};s.push(t),safeSet(E,JSON.stringify(s)),window.APPS&&!window.APPS.some(o=>o.id===c.id)&&(window.APPS.push(t),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=n.usedExisting?`${c.name} configured with existing container!
URL: ${o.url}`:`${c.name} deployed successfully! URL: ${n.url}`:`${c.name} deployed successfully!
URL: ${o.url}`;o.warning&&(r+=` URL: ${n.url}`;n.warning&&(i+=`
\u26A0 Warning: ${o.warning}`),showNotification(r,"success",8e3),delete c._useExisting,delete c._existingContainer,o.url&&o.url.startsWith("https://")&&B(o.url,c.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const t=o.setupInstructions.join(` \u26A0 Warning: ${n.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,n.url&&n.url.startsWith("https://")&&C(n.url,c.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${c.name}: ${t}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(n){h.logError("[AppSelector] Deployment",n,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${n.message}`,"error",8e3)}}}async function B(d,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let i=0;const $=12,y=async()=>{i++;try{const n=await fetch(d,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return i<$?setTimeout(y,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating. `);showNotification(`Setup Instructions for ${c.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(a){b.logError("[AppSelector] Deployment",a,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${a.message}`,"error",8e3)}}}async function C(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let s=0;const h=12,u=async()=>{s++;try{const a=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<h?setTimeout(u,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(y,3e3)}function S(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(i=>i.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const T=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const d=JSON.parse(T.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),i=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{i.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const $={appTemplate:d,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:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:i.length>0?i:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!$.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(d.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),k($)}),wireModal(T);const j=document.getElementById("folder-browser-modal"),H=document.getElementById("folder-browser-path"),R=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let g="",u=[],f=null;window.openFolderBrowser=function(d){f=d,u=d.value.split(",").map(c=>c.trim()).filter(c=>c),g="",p(),m(""),j.classList.add("show")};async function m(d){H.textContent=d||"Select a drive...",R.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const i=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(d)}`)).json();if(!i.success){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(i.error)}</div>`;return}g=i.path||"",H.textContent=g||"Select a drive...";let $="";i.parent&&i.parent!==i.path&&($+=`<div class="folder-item" data-path="${escapeHtml(i.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;"> Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(u,3e3)}function R(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(s=>s.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{I(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const M=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(M.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{s.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const h={appTemplate:r,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:c||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(!h.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}M.classList.remove("show"),$(h)}),wireModal(M);const j=document.getElementById("folder-browser-modal"),B=document.getElementById("folder-browser-path"),A=document.getElementById("folder-browser-list"),w=document.getElementById("folder-browser-selected"),z=document.getElementById("folder-browser-selected-list");let f="",p=[],y=null;window.openFolderBrowser=function(r){y=r,p=r.value.split(",").map(c=>c.trim()).filter(c=>c),f="",m(),v(""),j.classList.add("show")};async function v(r){B.textContent=r||"Select a drive...",A.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const s=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!s.success){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}f=s.path||"",B.textContent=f||"Select a drive...";let h="";s.parent&&s.parent!==s.path&&(h+=`<div class="folder-item" data-path="${escapeHtml(s.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span> <span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span> <span style="color: var(--muted);">.. Parent Directory</span>
</div>`),i.items.length===0&&!i.parent?$+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':i.items.length===0?$+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':i.items.forEach(y=>{const n=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=u.includes(y.path),o=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";$+=`<div class="folder-item" data-path="${escapeHtml(y.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${o}"> </div>`),s.items.length===0&&!s.parent?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':s.items.length===0?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(u=>{const a=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=p.includes(u.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";h+=`<div class="folder-item" data-path="${escapeHtml(u.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
<span style="font-size: 1.2rem;">${n}</span> <span style="font-size: 1.2rem;">${a}</span>
<span style="flex: 1;">${escapeHtml(y.name)}</span> <span style="flex: 1;">${escapeHtml(u.name)}</span>
${e?'<span style="color: var(--success);">\u2713</span>':""} ${e?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),R.innerHTML=$,R.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{m(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const n=u.includes(y.dataset.path);y.style.background=n?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function p(){if(u.length===0){x.style.display="none";return}x.style.display="block",D.innerHTML=u.map(d=>` </div>`}),A.innerHTML=h,A.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{v(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const a=p.includes(u.dataset.path);u.style.background=a?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function m(){if(p.length===0){w.style.display="none";return}w.style.display="block",z.innerHTML=p.map(r=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;"> <span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
${escapeHtml(d)} ${escapeHtml(r)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(d)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button> <button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span> </span>
`).join("")}window.removeSelectedFolder=function(d){u=u.filter(c=>c!==d),p(),m(g)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{g&&!u.includes(g)&&(u.push(g),p(),m(g))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{f&&(f.value=u.join(", ")),j.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal"> `).join("")}window.removeSelectedFolder=function(r){p=p.filter(c=>c!==r),m(),v(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!p.includes(f)&&(p.push(f),m(),v(f))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{y&&(y.value=p.join(", ")),j.classList.remove("show")}),R()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;"> <div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3> <h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="recipe-next" class="btn-accent-solid">Next</button> <button id="recipe-next" class="btn-accent-solid">Next</button>
</div> </div>
</div> </div>
</div>`);let h=null,E=null,P=null,w=1,N=!1;const O=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(O,z);async function L(){try{const g=await fetch("/api/v1/recipes/templates"),u=await g.json();if(u.success)return h=u.templates,E=u.categories,!0;if(g.status===403)return N=!1,!1}catch(g){console.warn("Failed to fetch recipe templates:",g.message)}return!1}async function b(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(g){await b();let u;if(N&&h?u=h:u=M(),!u||u.length===0)return;const f=document.createElement("div");f.className="app-category-header",f.innerHTML="\u{1F9EA} Recipes",f.style.borderBottomColor="#8e44ad",g.appendChild(f);const m=Array.isArray(u)?u:Object.values(u);m.sort((p,d)=>(d.popularity||0)-(p.popularity||0));for(const p of m){const d=document.createElement("div");d.className="app-option",d.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${p.componentCount||p.components?.length||"?"} apps</div>`,i=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';d.innerHTML=` </div>`);let b=null,E=null,N=null,S=1,T=!1;const P=document.getElementById("recipe-deploy-modal"),L=document.getElementById("recipe-cancel"),H=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(P,L);async function I(){try{const f=await fetch("/api/v1/recipes/templates"),p=await f.json();if(p.success)return b=p.templates,E=p.categories,!0;if(f.status===403)return T=!1,!1}catch(f){console.warn("Failed to fetch recipe templates:",f.message)}return!1}async function k(){try{T=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{T=!1}return T}window.renderRecipeCards=async function(f){await k();let p;if(T&&b?p=b:p=x(),!p||p.length===0)return;const y=document.createElement("div");y.className="app-category-header",y.innerHTML="\u{1F9EA} Recipes",y.style.borderBottomColor="#8e44ad",f.appendChild(y);const v=Array.isArray(p)?p:Object.values(p);v.sort((m,r)=>(r.popularity||0)-(m.popularity||0));for(const m of v){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${m.componentCount||m.components?.length||"?"} apps</div>`,s=T?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
${i} ${s}
<div class="app-option-icon">${escapeHtml(p.icon||"\u{1F9EA}")}</div> <div class="app-option-icon">${escapeHtml(m.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(p.name)}</div> <div class="app-option-name">${escapeHtml(m.name)}</div>
<div class="app-option-desc">${escapeHtml(p.description||"")}</div> <div class="app-option-desc">${escapeHtml(m.description||"")}</div>
${c} ${c}
`,d.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(p)},g.appendChild(d)}};function M(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(g){P=g,w=1;const u=document.getElementById("app-selector-modal");u&&u.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${g.name}`,B(),S(),O.classList.add("show")}function B(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(g=>{const u=parseInt(g.dataset.step);g.classList.toggle("active",u===w),g.classList.toggle("completed",u<w)});for(let g=1;g<=4;g++){const u=document.getElementById(`recipe-step-${g}`);u&&(u.style.display=g===w?"":"none")}A.style.display=w>1&&w<4?"":"none",w===4?(v.style.display="none",z.textContent="Close"):w===3?(v.textContent="\u{1F680} Deploy",v.style.display="",z.textContent="Cancel"):(v.textContent="Next",v.style.display="",z.textContent="Cancel")}function S(){const g=document.getElementById("recipe-component-list");g.innerHTML="";const u=P.components||[];for(const f of u){const m=document.createElement("div");m.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const p=f.required,d=f.internal;m.innerHTML=` `,r.onclick=()=>{if(!T){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}$(m)},f.appendChild(r)}};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 $(f){N=f,S=1;const p=document.getElementById("app-selector-modal");p&&p.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${f.name}`,C(),R(),P.classList.add("show")}function C(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(f=>{const p=parseInt(f.dataset.step);f.classList.toggle("active",p===S),f.classList.toggle("completed",p<S)});for(let f=1;f<=4;f++){const p=document.getElementById(`recipe-step-${f}`);p&&(p.style.display=f===S?"":"none")}H.style.display=S>1&&S<4?"":"none",S===4?(g.style.display="none",L.textContent="Close"):S===3?(g.textContent="\u{1F680} Deploy",g.style.display="",L.textContent="Cancel"):(g.textContent="Next",g.style.display="",L.textContent="Cancel")}function R(){const f=document.getElementById("recipe-component-list");f.innerHTML="";const p=N.components||[];for(const y of p){const v=document.createElement("div");v.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const m=y.required,r=y.internal;v.innerHTML=`
<input type="checkbox" ${p?"checked disabled":"checked"} data-component-id="${f.id}" <input type="checkbox" ${m?"checked disabled":"checked"} data-component-id="${y.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" /> style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(f.role||f.id)}</div> <div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(y.role||y.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);"> <div style="font-size: 0.78rem; color: var(--muted);">
${f.templateRef?escapeHtml(f.templateRef):"Built-in"} ${y.templateRef?escapeHtml(y.templateRef):"Built-in"}
${p?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'} ${m?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${d?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""} ${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</div> </div>
${f.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(f.note)}</div>`:""} ${y.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(y.note)}</div>`:""}
</div> </div>
`,g.appendChild(m)}}function T(){const g=document.getElementById("recipe-volumes-section"),u=document.getElementById("recipe-volume-list"),f=P.sharedVolumes;if(f&&Object.keys(f).length>0){g.style.display="",u.innerHTML="";for(const[m,p]of Object.entries(f)){const d=document.createElement("div");d.style.cssText="display: grid; gap: 4px;",d.innerHTML=` `,f.appendChild(v)}}function M(){const f=document.getElementById("recipe-volumes-section"),p=document.getElementById("recipe-volume-list"),y=N.sharedVolumes;if(y&&Object.keys(y).length>0){f.style.display="",p.innerHTML="";for(const[v,m]of Object.entries(y)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(p.label||m)}</label> <label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(m.label||v)}</label>
<input type="text" data-volume-key="${m}" value="${escapeHtml(p.defaultPath||"")}" <input type="text" data-volume-key="${v}" value="${escapeHtml(m.defaultPath||"")}"
placeholder="${escapeHtml(p.defaultPath||"/path")}" placeholder="${escapeHtml(m.defaultPath||"/path")}"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" /> style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
<div class="form-hint-sm">${escapeHtml(p.description||"")}</div> <div class="form-hint-sm">${escapeHtml(m.description||"")}</div>
`,u.appendChild(d)}}else g.style.display="none"}function j(){const g=document.getElementById("recipe-review-content"),u=H(),f=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};f.forEach(i=>{m[i.dataset.volumeKey]=i.value});const p=document.getElementById("recipe-timezone").value||"UTC",d=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;g.innerHTML=` `,p.appendChild(r)}}else f.style.display="none"}function j(){const f=document.getElementById("recipe-review-content"),p=B(),y=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};y.forEach(s=>{v[s.dataset.volumeKey]=s.value});const m=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;f.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(P.name)}</div> <div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(N.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(P.description||"")}</div> <div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(N.description||"")}</div>
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<strong>Components (${u.length}):</strong> <strong>Components (${p.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;"> <div style="display: grid; gap: 4px; margin-top: 6px;">
${u.map(i=>`<div style="padding: 4px 0; font-size: 0.85rem;"> ${p.map(s=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(i.role||i.id)}</strong> ${i.internal?'<span style="color:var(--muted)">(internal)</span>':""} \u2022 <strong>${escapeHtml(s.role||s.id)}</strong> ${s.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")} </div>`).join("")}
</div> </div>
</div> </div>
${Object.keys(m).length>0?`<div style="margin-bottom: 12px;"> ${Object.keys(v).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong> <strong>Volumes:</strong>
${Object.entries(m).map(([i,$])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${i}: ${escapeHtml($)}</div>`).join("")} ${Object.entries(v).map(([s,h])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${s}: ${escapeHtml(h)}</div>`).join("")}
</div>`:""} </div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);"> <div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(p)} &bull; IP: ${escapeHtml(d)} ${c?"&bull; Tailscale only":""} Timezone: ${escapeHtml(m)} &bull; IP: ${escapeHtml(r)} ${c?"&bull; Tailscale only":""}
</div> </div>
${P.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(P.network.name)}</code></div>`:""} ${N.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(N.network.name)}</code></div>`:""}
`}function H(){const g=document.querySelectorAll("#recipe-component-list input[data-component-id]"),u=new Set;g.forEach(m=>{m.checked&&u.add(m.dataset.componentId)});const f=P.components||[];return f.filter(m=>m.required).forEach(m=>u.add(m.id)),f.filter(m=>u.has(m.id))}async function R(){const g=document.getElementById("recipe-progress-list"),u=document.getElementById("recipe-deploy-result");u.style.display="none",g.innerHTML="";const f=H();for(const c of f){const i=document.createElement("div");i.id=`recipe-progress-${c.id}`,i.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;",i.innerHTML=` `}function B(){const f=document.querySelectorAll("#recipe-component-list input[data-component-id]"),p=new Set;f.forEach(v=>{v.checked&&p.add(v.dataset.componentId)});const y=N.components||[];return y.filter(v=>v.required).forEach(v=>p.add(v.id)),y.filter(v=>p.has(v.id))}async function A(){const f=document.getElementById("recipe-progress-list"),p=document.getElementById("recipe-deploy-result");p.style.display="none",f.innerHTML="";const y=B();for(const c of y){const s=document.createElement("div");s.id=`recipe-progress-${c.id}`,s.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;",s.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span> <span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span> <span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span> <span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,g.appendChild(i)}const m=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};m.forEach(c=>{p[c.dataset.volumeKey]=c.value});const d={selectedComponents:f.map(c=>c.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:p},componentOverrides:{}};for(const c of f)x(c.id,"deploying","Deploying...");try{const i=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:P.id,config:d})})).json();if(i.success){for(const $ of i.deployed||[])x($.id,"success",$.url?`Running \u2192 ${$.url}`:"Running");for(const $ of i.errors||[])x($.componentId,"error",$.error);u.style.display="",u.innerHTML=` `,f.appendChild(s)}const v=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};v.forEach(c=>{m[c.dataset.volumeKey]=c.value});const r={selectedComponents:y.map(c=>c.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:m},componentOverrides:{}};for(const c of y)w(c.id,"deploying","Deploying...");try{const s=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:N.id,config:r})})).json();if(s.success){for(const h of s.deployed||[])w(h.id,"success",h.url?`Running \u2192 ${h.url}`:"Running");for(const h of s.errors||[])w(h.componentId,"error",h.error);p.style.display="",p.innerHTML=`
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);"> <div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(i.message||"Deployed!")}</div> <div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(s.message||"Deployed!")}</div>
${i.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;"> ${s.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong> <strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${i.setupInstructions.map($=>`<li>${escapeHtml($)}</li>`).join("")}</ul> <ul style="margin: 4px 0 0 16px; padding: 0;">${s.setupInstructions.map(h=>`<li>${escapeHtml(h)}</li>`).join("")}</ul>
</div>`:""} </div>`:""}
</div> </div>
`,showNotification(`${P.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);"> `,showNotification(`${N.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else p.style.display="",p.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(i.error||"Unknown error")} <strong>Deployment failed:</strong> ${escapeHtml(s.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${i.error}`,"error",5e3)}catch(c){u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);"> </div>`,showNotification(`Recipe deployment failed: ${s.error}`,"error",5e3)}catch(c){p.style.display="",p.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(c.message)} <strong>Network error:</strong> ${escapeHtml(c.message)}
</div>`}}function x(g,u,f){const m=document.getElementById(`recipe-progress-${g}`);if(!m)return;const p=m.querySelector(".recipe-progress-icon"),d=m.querySelector(".recipe-progress-status");u==="deploying"?(p.textContent="\u23F3",d.style.color="var(--accent)"):u==="success"?(p.textContent="\u2705",d.style.color="var(--ok-fg)"):u==="error"&&(p.textContent="\u274C",d.style.color="var(--bad-fg)"),d.textContent=f}v.addEventListener("click",()=>{if(w===3){w=4,B(),R();return}w<3&&(w++,B(),w===2&&T(),w===3&&j())}),A.addEventListener("click",()=>{w>1&&w<4&&(w--,B())}),window.groupRecipeCards=function(){const g=document.querySelectorAll(".service-card[data-recipe-id]");if(g.length===0)return;const u={};g.forEach(f=>{const m=f.dataset.recipeId;u[m]||(u[m]=[]),u[m].push(f)});for(const[f,m]of Object.entries(u))m.length<2||m.forEach((p,d)=>{if(p.style.borderLeft="3px solid rgba(142,68,173,0.5)",d===0){let c=p.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.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;",c.textContent=f.replace(/-/g," "),p.style.position="relative",p.appendChild(c))}})},window.manageRecipe=async function(g,u){const f=`/api/v1/recipes/${g}/${u}`,m=u==="remove"?"DELETE":"POST",p=u==="remove"?`/api/v1/recipes/${g}`:f;if(!(u==="remove"&&!confirm(`Remove the entire ${g} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(p,{method:m})).json();c.success?(showNotification(`Recipe ${u}: ${c.results?.filter(i=>i.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${u} failed: ${c.error}`,"error",5e3)}catch(d){showNotification(`Network error: ${d.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=` </div>`}}function w(f,p,y){const v=document.getElementById(`recipe-progress-${f}`);if(!v)return;const m=v.querySelector(".recipe-progress-icon"),r=v.querySelector(".recipe-progress-status");p==="deploying"?(m.textContent="\u23F3",r.style.color="var(--accent)"):p==="success"?(m.textContent="\u2705",r.style.color="var(--ok-fg)"):p==="error"&&(m.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=y}g.addEventListener("click",()=>{if(S===3){S=4,C(),A();return}S<3&&(S++,C(),S===2&&M(),S===3&&j())}),H.addEventListener("click",()=>{S>1&&S<4&&(S--,C())}),window.groupRecipeCards=function(){const f=document.querySelectorAll(".service-card[data-recipe-id]");if(f.length===0)return;const p={};f.forEach(y=>{const v=y.dataset.recipeId;p[v]||(p[v]=[]),p[v].push(y)});for(const[y,v]of Object.entries(p))v.length<2||v.forEach((m,r)=>{if(m.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=m.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.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;",c.textContent=y.replace(/-/g," "),m.style.position="relative",m.appendChild(c))}})},window.manageRecipe=async function(f,p){const y=`/api/v1/recipes/${f}/${p}`,v=p==="remove"?"DELETE":"POST",m=p==="remove"?`/api/v1/recipes/${f}`:y;if(!(p==="remove"&&!confirm(`Remove the entire ${f} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(m,{method:v})).json();c.success?(showNotification(`Recipe ${p}: ${c.results?.filter(s=>s.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${p} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const z=document.createElement("style");z.textContent=`
.recipe-step { .recipe-step {
flex: 1; flex: 1;
text-align: center; text-align: center;
@@ -550,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel { .recipe-step-panel {
min-height: 180px; min-height: 180px;
} }
`,document.head.appendChild(D),b()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),E=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const P=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),w=await P.json();if(P.ok&&w.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3);else throw new Error(w.error||"Reload failed")}catch(P){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${P.message}`,"error"),setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),P=document.getElementById("view-error-logs"),w=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),O=document.getElementById("error-log-close");async function z(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const L=await(await fetch("/api/v1/error-logs")).json();L.success&&L.logs?L.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=L.logs.map(b=>` `,document.head.appendChild(z),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),E=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const N=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),S=await N.json();if(N.ok&&S.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3);else throw new Error(S.error||"Reload failed")}catch(N){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${N.message}`,"error"),setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const b=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),N=document.getElementById("view-error-logs"),S=document.getElementById("error-log-refresh"),T=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function L(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const I=await(await fetch("/api/v1/error-logs")).json();I.success&&I.logs?I.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=I.logs.map(k=>`
<div class="log-entry error"> <div class="log-entry error">
<span class="log-timestamp">${new Date(b.timestamp).toLocaleString()}</span> <span class="log-timestamp">${new Date(k.timestamp).toLocaleString()}</span>
<span class="log-level">ERROR</span> <span class="log-level">ERROR</span>
<div class="log-message"> <div class="log-message">
<strong>${escapeHtml(b.context)}</strong>: ${escapeHtml(b.error)} <strong>${escapeHtml(k.context)}</strong>: ${escapeHtml(k.error)}
${b.details?`<br><small style="opacity: 0.7;">${escapeHtml(b.details)}</small>`:""} ${k.details?`<br><small style="opacity: 0.7;">${escapeHtml(k.details)}</small>`:""}
</div> </div>
</div> </div>
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}P?.addEventListener("click",()=>{h.classList.add("show"),z()}),w?.addEventListener("click",z),N?.addEventListener("click",A),wireModal(h,O)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;"> `).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function H(){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),L()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}N?.addEventListener("click",()=>{b.classList.add("show"),L()}),S?.addEventListener("click",L),T?.addEventListener("click",H),wireModal(b,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;"> <div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;"> <div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div> <div>
@@ -609,14 +609,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="cl-close-btn" class="btn-secondary">Close</button> <button id="cl-close-btn" class="btn-secondary">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),P=document.getElementById("cl-log-content"),w=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),O=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),A=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),b=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),B=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),H=document.getElementById("cl-status"),R=document.getElementById("cl-created");let x=null,D=[],g=[],u=null,f=!1,m=null;function p(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function d(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const C=s.stream==="stderr"?"log-stderr":"log-stdout",I=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return` </div>`);const b=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),N=document.getElementById("cl-log-content"),S=document.getElementById("cl-log-search"),T=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),L=document.getElementById("cl-stream"),H=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),I=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),x=document.getElementById("cl-stream-status"),$=document.getElementById("cl-stream-indicator"),C=document.getElementById("cl-stream-text"),R=document.getElementById("cl-line-count"),M=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),B=document.getElementById("cl-status"),A=document.getElementById("cl-created");let w=null,z=[],f=[],p=null,y=!1,v=null;function m(d){if(!d)return"-";const l=new Date(d);return isNaN(l.getTime())?d:l.toLocaleString()}function r(d){if(!d)return"";const l=document.createElement("div");return l.textContent=d,l.innerHTML}function c(d,l){const D=d.stream==="stderr"?"log-stderr":"log-stdout",O=d.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${C}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;"> <div class="log-entry ${D}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span> <span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${I}</span> <span class="log-stream-icon" style="color: ${d.stream==="stderr"?"#f59e0b":"#22c55e"};">${O}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${d(s.text)}</span> <span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(d.text)}</span>
</div> </div>
`}function i(s,l=""){if(!s||s.length===0){P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",T.textContent="0 filtered";return}if(D=s,g=l?s.filter(C=>C.text&&C.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,T.textContent=l?`${g.length} of ${s.length} shown`:`${s.length} shown`,g.length===0){P.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${d(l)}"</div>`;return}P.innerHTML=g.map((C,I)=>c(C,I)).join(""),P.scrollTop=P.scrollHeight}async function $(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],C=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(I=>{const F=document.createElement("option");F.value=I.id,F.textContent=`${I.name} (${I.image.split(":")[0]}) - ${I.status}`,F.dataset.name=I.name,F.dataset.image=I.image,F.dataset.status=I.status,F.dataset.created=I.created,E.appendChild(F)}),C&&E.querySelector(`option[value="${C}"]`)&&(E.value=C,y(C))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=E.querySelector(`option[value="${s}"]`);l&&(j.textContent=l.dataset.image||"-",H.textContent=l.dataset.status||"-",H.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",R.textContent=p(l.dataset.created))}async function n(){const s=E.value;if(!s){P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}o(),x=s,y(s);const l=N.value,C=w.value.trim();P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const I=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(I);F.logs&&F.logs.length>0?i(F.logs,C):(P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',S.textContent="0 lines",T.textContent="0 filtered")}catch(I){P.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${d(I.message)}</div>`}}function e(){const s=E.value;if(!s)return;o(),f=!0,z.textContent="\u23F9 Stop",M.style.display="flex",k.textContent="\u{1F7E2}",B.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;u=new EventSource(l),u.onopen=()=>{k.textContent="\u{1F7E2}",B.textContent="Connected - streaming logs"},u.onmessage=C=>{try{const I=JSON.parse(C.data);if(I.error){k.textContent="\u{1F534}",B.textContent=`Error: ${I.error}`;return}D.push(I),g.push(I),S.textContent=`${D.length} lines`,T.textContent=`${g.length} shown`;const F=w.value.trim();if(!F||I.text&&I.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(I,g.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",P.appendChild(U),P.scrollTop=P.scrollHeight}}catch(I){console.error("Error parsing log:",I)}},u.onerror=()=>{k.textContent="\u{1F534}",B.textContent="Disconnected",f=!1,z.textContent="\u25B6 Stream"},h._eventSource=u}function o(){u&&(u.close(),u=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),f=!1,z.textContent="\u25B6 Stream",M.style.display="none"}function a(){if(!D||D.length===0){showNotification("No logs to download","error");return}const s=E.querySelector(`option[value="${x}"]`)?.dataset.name||x,l=new Date().toISOString().replace(/[:.]/g,"-"),C=`${s}-logs-${l}.txt`,I=D.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(` `}function s(d,l=""){if(!d||d.length===0){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',R.textContent="0 lines",M.textContent="0 filtered";return}if(z=d,f=l?d.filter(D=>D.text&&D.text.toLowerCase().includes(l.toLowerCase())):d,R.textContent=`${d.length} lines`,M.textContent=l?`${f.length} of ${d.length} shown`:`${d.length} shown`,f.length===0){N.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}N.innerHTML=f.map((D,O)=>c(D,O)).join(""),N.scrollTop=N.scrollHeight}async function h(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],D=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(O=>{const F=document.createElement("option");F.value=O.id,F.textContent=`${O.name} (${O.image.split(":")[0]}) - ${O.status}`,F.dataset.name=O.name,F.dataset.image=O.image,F.dataset.status=O.status,F.dataset.created=O.created,E.appendChild(F)}),D&&E.querySelector(`option[value="${D}"]`)&&(E.value=D,u(D))}catch(d){console.error("Failed to load containers:",d)}}function u(d){const l=E.querySelector(`option[value="${d}"]`);l&&(j.textContent=l.dataset.image||"-",B.textContent=l.dataset.status||"-",B.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",A.textContent=m(l.dataset.created))}async function a(){const d=E.value;if(!d){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),w=d,u(d);const l=T.value,D=S.value.trim();N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const O=`/api/v1/logs/container/${d}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(O);F.logs&&F.logs.length>0?s(F.logs,D):(N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',R.textContent="0 lines",M.textContent="0 filtered")}catch(O){N.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(O.message)}</div>`}}function e(){const d=E.value;if(!d)return;n(),y=!0,L.textContent="\u23F9 Stop",x.style.display="flex",$.textContent="\u{1F7E2}",C.textContent="Connecting...";const l=`/api/v1/logs/stream/${d}`;p=new EventSource(l),p.onopen=()=>{$.textContent="\u{1F7E2}",C.textContent="Connected - streaming logs"},p.onmessage=D=>{try{const O=JSON.parse(D.data);if(O.error){$.textContent="\u{1F534}",C.textContent=`Error: ${O.error}`;return}z.push(O),f.push(O),R.textContent=`${z.length} lines`,M.textContent=`${f.length} shown`;const F=S.value.trim();if(!F||O.text&&O.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(O,f.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",N.appendChild(U),N.scrollTop=N.scrollHeight}}catch(O){console.error("Error parsing log:",O)}},p.onerror=()=>{$.textContent="\u{1F534}",C.textContent="Disconnected",y=!1,L.textContent="\u25B6 Stream"},b._eventSource=p}function n(){p&&(p.close(),p=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),y=!1,L.textContent="\u25B6 Stream",x.style.display="none"}function t(){if(!z||z.length===0){showNotification("No logs to download","error");return}const d=E.querySelector(`option[value="${w}"]`)?.dataset.name||w,l=new Date().toISOString().replace(/[:.]/g,"-"),D=`${d}-logs-${l}.txt`,O=z.map(G=>{const W=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${W?W+" ":""}${X} ${G.text}`}).join(`
`),F=new Blob([I],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=C,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${D.length} log lines`,"success")}E?.addEventListener("change",()=>{n()}),N?.addEventListener("change",()=>{n()}),O?.addEventListener("click",()=>{n()}),z?.addEventListener("click",()=>{f?o():e()}),A?.addEventListener("click",()=>{a()}),v?.addEventListener("click",()=>{w.value="",i(D,"")}),w?.addEventListener("input",()=>{clearTimeout(m),m=setTimeout(()=>{i(D,w.value.trim())},300)}),w?.addEventListener("keydown",s=>{s.key==="Escape"&&(w.value="",i(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),$()});function t(){o(),h.classList.remove("show")}L?.addEventListener("click",t),b?.addEventListener("click",t),document.addEventListener("keydown",s=>{s.key==="Escape"&&h.classList.contains("show")&&t()}),h.addEventListener("click",s=>{s.target===h&&t()}),window.openContainerLogsModal=function(s,l){h.classList.add("show"),$().then(()=>{const C=Array.from(E.options).find(I=>I.value===s||I.dataset.name===l);C?(E.value=C.value,y(C.value),n()):s?(x=s,j.textContent=l||s,H.textContent="-",R.textContent="-",n()):P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal"> `),F=new Blob([O],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=D,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${z.length} log lines`,"success")}E?.addEventListener("change",()=>{a()}),T?.addEventListener("change",()=>{a()}),P?.addEventListener("click",()=>{a()}),L?.addEventListener("click",()=>{y?n():e()}),H?.addEventListener("click",()=>{t()}),g?.addEventListener("click",()=>{S.value="",s(z,"")}),S?.addEventListener("input",()=>{clearTimeout(v),v=setTimeout(()=>{s(z,S.value.trim())},300)}),S?.addEventListener("keydown",d=>{d.key==="Escape"&&(S.value="",s(z,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),h()});function o(){n(),b.classList.remove("show")}I?.addEventListener("click",o),k?.addEventListener("click",o),document.addEventListener("keydown",d=>{d.key==="Escape"&&b.classList.contains("show")&&o()}),b.addEventListener("click",d=>{d.target===b&&o()}),window.openContainerLogsModal=function(d,l){b.classList.add("show"),h().then(()=>{const D=Array.from(E.options).find(O=>O.value===d||O.dataset.name===l);D?(E.value=D.value,u(D.value),a()):d?(w=d,j.textContent=l||d,B.textContent="-",A.textContent="-",a()):N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;"> <div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>\u{1F4BE} Container Snapshots</h3> <h3>\u{1F4BE} Container Snapshots</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -670,7 +670,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="snapshot-close">Close</button> <button id="snapshot-close">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),P=document.getElementById("snapshot-close"),w=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),O=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let A=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;w.innerHTML='<option value="">-- Select a container --</option>';for(const T of S.containers){const j=document.createElement("option");j.value=T.id,j.textContent=`${T.name||T.id} (${T.image||"unknown"})`,j.dataset.name=T.name,j.dataset.image=T.image,j.dataset.status=T.status,j.dataset.created=T.created,w.appendChild(j)}}catch(B){console.error("Failed to load containers:",B)}}function L(B){if(!B||!B.value){N.style.display="none",A=null;return}A=B.value,document.getElementById("snapshot-image").textContent=B.dataset.image||"-",document.getElementById("snapshot-status").textContent=B.dataset.status||"-",document.getElementById("snapshot-created").textContent=B.dataset.created?new Date(B.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=B.value.substring(0,12),N.style.display=""}async function b(){if(!A){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const B=document.getElementById("snapshot-name").value.trim();if(!B){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;O.disabled=!0,O.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(A)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,leaveRunning:S})})).json();j.success?(z.textContent=`\u2713 Snapshot "${B}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(T){z.textContent=`\u2717 Error: ${T.message}`,z.style.color="var(--bad-fg)"}finally{O.disabled=!1,O.textContent="\u{1F4BE} Create Snapshot"}}function M(){h.classList.add("show"),v()}function k(){h.classList.remove("show"),N.style.display="none",A=null,w.selectedIndex=0}E?.addEventListener("click",M),P?.addEventListener("click",k),wireModal(h,P),w?.addEventListener("change",B=>{const S=w.options[w.selectedIndex];L(S)}),O?.addEventListener("click",b),h?.querySelectorAll(".panel-tab").forEach(B=>{B.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),B.classList.add("active"),h.querySelector(`#${B.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal"> </div>`);const b=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),N=document.getElementById("snapshot-close"),S=document.getElementById("snapshot-container-select"),T=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),L=document.getElementById("snapshot-create-status");let H=null;async function g(){try{const R=await(await fetch("/api/v1/containers")).json();if(!R.success||!R.containers)return;S.innerHTML='<option value="">-- Select a container --</option>';for(const M of R.containers){const j=document.createElement("option");j.value=M.id,j.textContent=`${M.name||M.id} (${M.image||"unknown"})`,j.dataset.name=M.name,j.dataset.image=M.image,j.dataset.status=M.status,j.dataset.created=M.created,S.appendChild(j)}}catch(C){console.error("Failed to load containers:",C)}}function I(C){if(!C||!C.value){T.style.display="none",H=null;return}H=C.value,document.getElementById("snapshot-image").textContent=C.dataset.image||"-",document.getElementById("snapshot-status").textContent=C.dataset.status||"-",document.getElementById("snapshot-created").textContent=C.dataset.created?new Date(C.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=C.value.substring(0,12),T.style.display=""}async function k(){if(!H){L.textContent="Please select a container first",L.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-name").value.trim();if(!C){L.textContent="Please enter a snapshot name",L.style.color="var(--bad-fg)";return}const R=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",L.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(H)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:C,leaveRunning:R})})).json();j.success?(L.textContent=`\u2713 Snapshot "${C}" created successfully`,L.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(L.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,L.style.color="var(--bad-fg)")}catch(M){L.textContent=`\u2717 Error: ${M.message}`,L.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function x(){b.classList.add("show"),g()}function $(){b.classList.remove("show"),T.style.display="none",H=null,S.selectedIndex=0}E?.addEventListener("click",x),N?.addEventListener("click",$),wireModal(b,N),S?.addEventListener("change",C=>{const R=S.options[S.selectedIndex];I(R)}),P?.addEventListener("click",k),b?.querySelectorAll(".panel-tab").forEach(C=>{C.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(R=>R.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(R=>R.classList.remove("active")),C.classList.add("active"),b.querySelector(`#${C.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;"> <div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3> <h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;"> <p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -749,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button> <button id="arr-setup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),P=document.getElementById("arr-setup-cancel"),w=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),O=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),b=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),B=document.getElementById("smart-retry-btn");let S=null;const T={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function H(m){N.style.display=m==="detect"?"block":"none",O.style.display=m==="credentials"?"block":"none",z.style.display=m==="progress"?"block":"none",A.style.display=m==="results"?"block":"none"}function R(m){const p={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},d=p[m]||p.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${d.bg} 20%, transparent); color: ${d.bg};">${d.icon} ${d.text}</span>`}async function x(){H("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,v.style.display="block";return}let p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,i]of Object.entries(S.services)){const $=T[c]||"\u{1F4E6}",y=j[c]||c,n=i.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(i.source)}</span>`:"",e=i.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(i.version)}</span>`:"",o=(i.hasApiKey||i.hasToken)&&i.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";p+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`);const b=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),N=document.getElementById("arr-setup-cancel"),S=document.getElementById("smart-connect-btn"),T=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),L=document.getElementById("smart-phase-progress"),H=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),I=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),$=document.getElementById("smart-plex-libraries"),C=document.getElementById("smart-retry-btn");let R=null;const M={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function B(v){T.style.display=v==="detect"?"block":"none",P.style.display=v==="credentials"?"block":"none",L.style.display=v==="progress"?"block":"none",H.style.display=v==="results"?"block":"none"}function A(v){const m={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},r=m[v]||m.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function w(){B("detect"),g.style.display="none";try{if(R=await(await fetch("/api/v1/arr/smart-detect")).json(),!R.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(R.error)}</div>`,g.style.display="block";return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,s]of Object.entries(R.services)){const h=M[c]||"\u{1F4E6}",u=j[c]||c,a=s.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(s.source)}</span>`:"",e=s.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(s.version)}</span>`:"",n=(s.hasApiKey||s.hasToken)&&s.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";m+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${$}</span> <span style="font-size: 1.1rem;">${h}</span>
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${y}</div> <div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;"> <div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${n} ${e} ${o} ${a} ${e} ${n}
</div> </div>
</div> </div>
${R(i.status)} ${A(s.status)}
</div>`}p+="</div>";const d=S.summary;p+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;"> </div>`}m+="</div>";const r=R.summary;m+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(d.fullyConnected))}/${escapeHtml(String(d.totalDetected+(5-d.totalDetected)))} services detected &middot; ${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected &middot;
${escapeHtml(String(d.fullyConnected))} connected${d.needsApiKey>0?` &middot; <strong>${escapeHtml(String(d.needsApiKey))} needs API key</strong>`:""} ${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` &middot; <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
</div>`,v.innerHTML=p,v.style.display="block",D(S),setTimeout(()=>{H("credentials")},800)}catch(m){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(m.message)}</div>`,v.style.display="block"}}function D(m){let p="";const d=m.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const n=d[y];if(!n||n.status==="not_found"&&!n.url)continue;const e=T[y],o=j[y],a=n.status==="connected";p+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"var(--ok-fg)":"var(--border)"};"> </div>`,g.innerHTML=m,g.style.display="block",z(R),setTimeout(()=>{B("credentials")},800)}catch(v){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(v.message)}</div>`,g.style.display="block"}}function z(v){let m="";const r=v.services,c=["radarr","sonarr","prowlarr"];for(const u of c){const a=r[u];if(!a||a.status==="not_found"&&!a.url)continue;const e=M[u],n=j[u],t=a.status==="connected";m+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${t?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${e}</span> <span style="font-size: 1.1rem;">${e}</span>
<span style="font-weight: 500;">${o}</span> <span style="font-weight: 500;">${n}</span>
<span id="smart-${y}-status" style="margin-left: auto; font-size: 0.75rem;"> <span id="smart-${u}-status" style="margin-left: auto; font-size: 0.75rem;">
${a?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""} ${t?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span> </span>
</div> </div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;"> <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label> <label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
<input type="text" id="smart-${y}-url" value="${escapeHtml(n.url||"")}" placeholder="https://seedbox.com/${y}/" <input type="text" id="smart-${u}-url" value="${escapeHtml(a.url||"")}" placeholder="https://seedbox.com/${u}/"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" /> style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div> </div>
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label> <label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${y}-key" placeholder="${a?"(saved)":"Paste API key"}" <input type="password" id="smart-${u}-key" placeholder="${t?"(saved)":"Paste API key"}"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" /> style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div> </div>
</div> </div>
<button onclick="smartTestConnection('${y}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button> <button onclick="smartTestConnection('${u}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const i=d.plex;if(i){const y=i.status==="connected";p+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;"> </div>`}const s=r.plex;if(s){const u=s.status==="connected";m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span> <span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span> <span style="font-weight: 500;">Plex</span>
${R(i.status)} ${A(s.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(i.source||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(s.source||"")}</span>
</div> </div>
</div>`}const $=d.seerr;if($){const y=$.status==="connected";let n="";if($.configuredServices){const e=$.configuredServices;n=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;"> </div>`}const h=r.seerr;if(h){const u=h.status==="connected";let a="";if(h.configuredServices){const e=h.configuredServices;a=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot; Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot; ${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${e.plex?"&#10003; Plex":"&#10007; Plex"} ${e.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}p+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"};"> </div>`}m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span> <span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span> <span style="font-weight: 500;">Seerr</span>
${R($.status)} ${A(h.status)}
</div> </div>
${n} ${a}
</div>`}L.innerHTML=p}window.smartTestConnection=async function(m){const p=document.getElementById(`smart-${m}-url`),d=document.getElementById(`smart-${m}-key`),c=document.getElementById(`smart-${m}-status`),i=p?.value.trim(),$=d?.value.trim();if(!i||!$){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const n=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:m,url:i,apiKey:$})})).json();n.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(n.appName||"Connected")} v${escapeHtml(n.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(n.error)}</span>`}catch(y){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(y.message)}</span>`}};async function g(){H("progress"),b.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const m={};for(const d of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${d}-url`)?.value.trim(),i=document.getElementById(`smart-${d}-key`)?.value.trim();i&&c?m[d]={apiKey:i,url:c}:i&&(m[d]={apiKey:i})}const p={services:Object.keys(m).length>0?m: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 c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(p)})).json();let i="";for(const $ of c.steps||[]){const y=$.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',n=$.status==="success"?"var(--muted)":"var(--bad-fg)";i+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;"> </div>`}I.innerHTML=m}window.smartTestConnection=async function(v){const m=document.getElementById(`smart-${v}-url`),r=document.getElementById(`smart-${v}-key`),c=document.getElementById(`smart-${v}-status`),s=m?.value.trim(),h=r?.value.trim();if(!s||!h){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const a=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:v,url:s,apiKey:h})})).json();a.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(a.appName||"Connected")} v${escapeHtml(a.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(a.error)}</span>`}catch(u){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(u.message)}</span>`}};async function f(){B("progress"),k.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const v={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),s=document.getElementById(`smart-${r}-key`)?.value.trim();s&&c?v[r]={apiKey:s,url:c}:s&&(v[r]={apiKey:s})}const m={services:Object.keys(v).length>0?v: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 c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)})).json();let s="";for(const h of c.steps||[]){const u=h.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',a=h.status==="success"?"var(--muted)":"var(--bad-fg)";s+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${y} ${u}
<span>${escapeHtml($.step)}</span> <span>${escapeHtml(h.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${n};">${escapeHtml($.details||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: ${a};">${escapeHtml(h.details||"")}</span>
</div>`}b.innerHTML=i,setTimeout(()=>u(c),500)}catch(d){b.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(d.message)}</div>`}}function u(m){H("results");const p=m.summary||{},d=p.failed===0&&p.succeeded>0,c=d?"var(--ok-fg)":"#f39c12",i=d?"&#10003;":"&#9888;",$=d?"All Connected!":`${escapeHtml(String(p.succeeded))}/${escapeHtml(String(p.totalSteps))} Steps Succeeded`;let y=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;"> </div>`}k.innerHTML=s,setTimeout(()=>p(c),500)}catch(r){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function p(v){B("results");const m=v.summary||{},r=m.failed===0&&m.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",s=r?"&#10003;":"&#9888;",h=r?"All Connected!":`${escapeHtml(String(m.succeeded))}/${escapeHtml(String(m.totalSteps))} Steps Succeeded`;let u=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${c};">${i}</div> <div style="font-size: 1.5rem; color: ${c};">${s}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${$}</div> <div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${h}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(p.succeeded))} succeeded, ${escapeHtml(String(p.failed))} failed</div> <div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(m.succeeded))} succeeded, ${escapeHtml(String(m.failed))} failed</div>
</div>`;y+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const n of m.steps||[]){const e=n.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';y+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;"> </div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const a of v.steps||[]){const e=a.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';u+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(n.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(n.details||"")}</span> ${e} ${escapeHtml(a.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(a.details||"")}</span>
</div>`}y+="</div>",M.innerHTML=y,B.style.display=p.failed>0?"block":"none",m.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&f()}async function f(){try{const p=await(await fetch("/api/v1/plex/libraries")).json();if(p.success&&p.libraries?.length>0){let d=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}u+="</div>",x.innerHTML=u,C.style.display=m.failed>0?"block":"none",v.steps?.some(a=>a.step.includes("Plex")&&a.status==="success")&&y()}async function y(){try{const m=await(await fetch("/api/v1/plex/libraries")).json();if(m.success&&m.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(p.serverName)} Libraries</h4> <h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(m.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of p.libraries){const i=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";d+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;"> <div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of m.libraries){const s=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${i} <strong>${escapeHtml(c.title)}</strong> ${s} <strong>${escapeHtml(c.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
</div>`}d+="</div></div>",k.innerHTML=d,k.style.display="block"}}catch{}}E?.addEventListener("click",()=>{h.classList.add("show"),k.style.display="none",x()}),wireModal(h,P),w?.addEventListener("click",g),B?.addEventListener("click",g)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal"> </div>`}r+="</div></div>",$.innerHTML=r,$.style.display="block"}}catch{}}E?.addEventListener("click",()=>{b.classList.add("show"),$.style.display="none",w()}),wireModal(b,N),S?.addEventListener("click",f),C?.addEventListener("click",f)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3> <h3>\u{1F514} Notification Settings</h3>
@@ -981,9 +981,6 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<label class="checkbox-label-sm"> <label class="checkbox-label-sm">
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed <input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
</label> </label>
<label class="checkbox-label-sm" style="grid-column: 1 / -1;">
<input type="checkbox" id="event-resource-alert" checked /> Resource Alerts
</label>
</div> </div>
<!-- History --> <!-- History -->
@@ -991,24 +988,22 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<div id="notification-history" style="max-height: 150px; overflow-y: auto; padding: 8px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); font-size: 0.8rem;"> <div id="notification-history" style="max-height: 150px; overflow-y: auto; padding: 8px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); font-size: 0.8rem;">
<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div> <div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>
</div> </div>
<div id="last-notification-sent" style="font-size: 0.75rem; color: var(--muted); text-align: center; margin-top: 6px;"></div>
<!-- Buttons --> <!-- Buttons -->
<div class="weather-modal-buttons modal-footer-bar"> <div class="weather-modal-buttons modal-footer-bar">
<button id="notifications-send-test" class="btn-secondary" style="margin-right: auto;">Send Test</button>
<button id="notifications-cancel">Cancel</button> <button id="notifications-cancel">Cancel</button>
<button id="notifications-save" class="btn-accent">Save Settings</button> <button id="notifications-save" class="btn-accent">Save Settings</button>
</div> </div>
</div> </div>
</div>`);const E=document.getElementById("notifications-modal"),P=document.getElementById("manage-notifications"),w=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const B=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);B?.addEventListener("change",()=>{S.style.display=B.checked?"block":"none"})});const O=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");O?.addEventListener("change",()=>{z.style.opacity=O.checked?"1":"0.5"});async function A(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const S=B.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(k){h.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function v(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");B.success&&B.history?.length>0?S.innerHTML=B.history.map(T=>{const j=new Date(T.timestamp).toLocaleString();return` </div>`);const E=document.getElementById("notifications-modal"),N=document.getElementById("manage-notifications"),S=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const $=document.getElementById(`${x}-enabled`),C=document.getElementById(`${x}-config`);$?.addEventListener("change",()=>{C.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),L=document.getElementById("health-check-config");P?.addEventListener("change",()=>{L.style.opacity=P.checked?"1":"0.5"});async function H(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const C=$.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.containerDown!==!1,document.getElementById("event-container-up").checked=C.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=C.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=C.events?.deploymentFailed!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");$.success&&$.history?.length>0?C.innerHTML=$.history.map(R=>{const M=new Date(R.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;"> <div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[T.type]||"var(--muted)"}">${T.type==="success"?"\u2713":T.type==="error"?"\u2717":"\u2139"}</span> <span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[R.type]||"var(--muted)"}">${R.type==="success"?"\u2713":R.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(T.title)}</div> <div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(R.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div> <div style="font-size: 0.7rem; color: var(--muted);">${M}</div>
</div> </div>
</div> </div>
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){h.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function L(){try{const k={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,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();S.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function b(k){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();S.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>b("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>b("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>b("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>b("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),P?.addEventListener("click",()=>{E.classList.add("show"),A(),v()}),w?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),B=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const T=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();T.success?(showNotification("Test notification sent!","success",3e3),M()):showNotification(`Test failed: ${T.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=B,k.disabled=!1}});async function M(){try{const B=await(await fetch("/api/v1/notifications/status")).json();if(B.success&&B.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(B.lastSent).toLocaleString()}`)}}catch{}}wireModal(E,N)})(),(function(){document.addEventListener("click",h=>{const E=h.target.closest(".panel-tab");if(!E)return;const P=E.dataset.panel;if(!P)return;const w=E.closest(".panel-tabs"),N=w.closest(".weather-modal-content");w.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),E.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const O=N.querySelector("#"+P);O&&O.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca Line truncated `}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function I(){try{const x={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}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();C.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function k(x){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();C.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.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 $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),N?.addEventListener("click",()=>{E.classList.add("show"),H(),g()}),S?.addEventListener("click",I),wireModal(E,T)})(),(function(){document.addEventListener("click",b=>{const E=b.target.closest(".panel-tab");if(!E)return;const N=E.dataset.panel;if(!N)return;const S=E.closest(".panel-tabs"),T=S.closest(".weather-modal-content");S.querySelectorAll(".panel-tab").forEach(L=>L.classList.remove("active")),E.classList.add("active"),T.querySelectorAll(".panel-section").forEach(L=>L.classList.remove("active"));const P=T.querySelector("#"+N);P&&P.classList.add("active")})})(),(function(){var b=["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 e={},n=0;n<b.length;n++){var t=b[n],i=safeGet(t);i!=null&&(e[t]=i)}try{for(var o=0;o<localStorage.length;o++){var d=localStorage.key(o);/^widget-.+-enabled$/.test(d)&&(e[d]=localStorage.getItem(d))}}catch{}return e}function N(e){if(!e||typeof e!="object")return 0;var n=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),n++);return n}function S(e){return e.version&&!e.files&&e.services}function T(e){var n={};e.customServices&&(n["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(n["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(n["weather-zip"]=e.weatherZip),e.theme&&(n.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(n["user-themes"]=JSON.stringify(e.userThemes)),N(n),e.services&&Array.isArray(e.serv Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3> <h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1018,9 +1013,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<!-- Tab bar --> <!-- Tab bar -->
<div class="panel-tabs"> <div class="panel-tabs">
<button class="panel-tab active" data-panel="backup-manual">Manual</button> <button class="panel-tab active" data-panel="backup-manual">Manual</button>
<button class="panel-tab" data-panel="backup-schedules-tab">Schedules</button> <button class="panel-tab" data-panel="backup-automated">Automated</button>
<button class="panel-tab" data-panel="backup-disk-tab">Backups on Disk</button>
<button class="panel-tab" data-panel="backup-pointintime-tab">Point-in-Time</button>
<button class="panel-tab" data-panel="backup-history-tab">History</button> <button class="panel-tab" data-panel="backup-history-tab">History</button>
</div> </div>
@@ -1069,32 +1062,12 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<div id="backup-result" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div> <div id="backup-result" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
</div> </div>
<!-- Tab: Schedules (Premium) --> <!-- Tab: Automated Backups -->
<div id="backup-schedules-tab" class="panel-section"> <div id="backup-automated" class="panel-section">
<div id="backup-schedules-container"> <div id="backup-schedule-container">
<div class="panel-empty"> <div class="panel-empty">
<span class="empty-icon">\u23F0</span> <span class="empty-icon">\u23F0</span>
<span class="brand-spinner"></span> Loading schedules... <span class="brand-spinner"></span> Loading backup schedule...
</div>
</div>
</div>
<!-- Tab: Backups on Disk -->
<div id="backup-disk-tab" class="panel-section">
<div id="backup-disk-container">
<div class="panel-empty">
<span class="empty-icon">\u{1F4BE}</span>
<span class="brand-spinner"></span> Loading backup files...
</div>
</div>
</div>
<!-- Tab: Point-in-Time Restore -->
<div id="backup-pointintime-tab" class="panel-section">
<div id="pointintime-container">
<div class="panel-empty">
<span class="empty-icon">\u23EA</span>
<span class="brand-spinner"></span> Loading...
</div> </div>
</div> </div>
</div> </div>
@@ -1114,9 +1087,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button> <button id="backup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);var O=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),b=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),B=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),H=document.getElementById("backup-history-container"),R=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),D=null;z?.addEventListener("click",function(){O.classList.add("show"),T&&(T.style.display="none"),k&&(k.style.display="none"),M&&(M.style.display="none"),D=null}),wireModal(O,A),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),o=await e.json();o.browserState=E();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),r=URL.createObjectURL(a),t=document.createElement("a");t.href=r,t.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(t),t.click(),document.body.removeChild(t),URL.revokeObjectURL(r);var s=Object.keys(o.browserState).length,l=o.themes?Object.keys(o.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),T.style.display="block",T.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",T.style.border="1px solid var(--ok-fg)"}catch(C){T.innerHTML="\u274C Export failed: "+escapeHtml(C.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)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){b.click()}),b?.addEventListener("change",async function(e){var o=e.target.files[0];if(o){M.textContent="\u{1F4C4} "+o.name,M.style.display="block",T.style.display="none";try{var a=await o.text(),r=JSON.parse(a);if(w(r)){D=r;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(r.version)+")</div>";t+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',r.services?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+r.services.length+" services</span>"),r.customApps?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+r.customApps.length+" custom apps</span>"),r.theme&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(r.theme)+"</span>"),r.userThemes&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(r.userThemes).length+" custom themes</span>"),t+="</div>",B.innerHTML=t,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),l=await s.json();if(l.success){D=r;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(r.exportedAt).toLocaleString()+" (v"+escapeHtml(r.version)+")</div>";t+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',t+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var C in l.preview.files){var I=l.preview.files[C],F=I.action==="create"?"\u{1F195}":"\u{1F4DD}";t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(I.description)+"</span>"}t+="</div>",l.preview.serviceCount&&(t+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(t+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(t+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',t+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),B.innerHTML=t,k.style.display="block"}else T.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),T.style.display="block",T.style.background="color-mix(in srgb, #f39c12 15%, transparent)",T.style.border="1px solid #f39c12",k.style.display="none"}catch(q){T.innerHTML="\u274CLine truncated </div>`);var P=document.getElementById("backup-modal"),L=document.getElementById("backup-restore-btn"),H=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),I=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),$=document.getElementById("backup-preview"),C=document.getElementById("backup-preview-content"),R=document.getElementById("backup-do-restore-btn"),M=document.getElementById("backup-result"),j=document.getElementById("backup-schedule-container"),B=document.getElementById("backup-history-container"),A=null;L?.addEventListener("click",function(){P.classList.add("show"),M&&(M.style.display="none"),$&&($.style.display="none"),x&&(x.style.display="none"),A=null}),wireModal(P,H),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),n=await e.json();n.browserState=E();var t=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),o=document.createElement("a");o.href=i,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(i);var d=Object.keys(n.browserState).length,l=n.themes?Object.keys(n.themes).length:0;M.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+d+" browser settings"+(l?" + "+l+" themes":""),M.style.display="block",M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)"}catch(D){M.innerHTML="\u274C Export failed: "+escapeHtml(D.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),I?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){x.textContent="\u{1F4C4} "+n.name,x.style.display="block",M.style.display="none";try{var t=await n.text(),i=JSON.parse(t);if(S(i)){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),o+="</div>",C.innerHTML=o,$.style.display="block";return}var d=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await d.json();if(l.success){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var D in l.preview.files){var O=l.preview.files[D],F=O.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(O.description)+"</span>"}o+="</div>",l.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),C.innerHTML=o,$.style.display="block"}else M.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),M.style.display="block",M.style.background="color-mix(in srgb, #f39c12 15%, transparent)",M.style.border="1px solid #f39c12",$.style.display="none"}catch(q){M.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),M.style.display="block",M.style.background="color-mix(in Line truncated
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),r=await a.json();r.success?(showNotification(e+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(r.error||"Unknown"),"error")}catch(t){showNotification("Restore error: "+t.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F4CA} Resource Monitor</h3> <h3>\u{1F4CA} Resource Monitor</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1196,10 +1167,10 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button> <button id="stats-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),P=document.getElementById("stats-cancel"),w=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),O=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let L=null,b=null;function M(i){if(i===0||!i)return"0 B";const $=1024,y=["B","KB","MB","GB"],n=Math.floor(Math.log(i)/Math.log($));return parseFloat((i/Math.pow($,n)).toFixed(1))+" "+y[n]}function k(i){return i<30?"#2ecc71":i<70?"#f39c12":"#e74c3c"}function B(i){return i<50?"#2ecc71":i<80?"#f39c12":"#e74c3c"}async function S(){try{let i=null,$=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(i=e.stats,$=!0,b=e.stats)}catch{}if(!$){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){i={};for(const o of e.stats)i[o.name]={name:o.name,current:{cpu:o.cpu,memory:{percent:o.memory.percent,usage:o.memory.used,limit:o.memory.limit,usageMB:Math.round(o.memory.used/1048576),limitMB:Math.round(o.memory.limit/1048576)},network:{rxBytes:o.network.rx,txBytes:o.network.tx,rxMB:(o.network.rx/1048576).toFixed(1),txMB:(o.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:o.status};b=i}}if(!i||Object.keys(i).length===0){O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[n,e]of Object.entries(i)){const o=e.current||e,a=o.cpu?.percent||0,r=o.memory?.percent||0,t=k(a),s=B(r),l=o.memory?.usage||o.memory?.used||0,C=o.memory?.limit||0,I=o.network?.rxBytes||o.network?.rx||0,F=o.network?.txBytes||o.network?.tx||0,q=e.aggregated;y+=` </div>`);const b=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),N=document.getElementById("stats-cancel"),S=document.getElementById("stats-refresh-btn"),T=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),L=document.getElementById("stats-aggregated-container"),H=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let I=null,k=null;function x(s){if(s===0||!s)return"0 B";const h=1024,u=["B","KB","MB","GB"],a=Math.floor(Math.log(s)/Math.log(h));return parseFloat((s/Math.pow(h,a)).toFixed(1))+" "+u[a]}function $(s){return s<30?"#2ecc71":s<70?"#f39c12":"#e74c3c"}function C(s){return s<50?"#2ecc71":s<80?"#f39c12":"#e74c3c"}async function R(){try{let s=null,h=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(s=e.stats,h=!0,k=e.stats)}catch{}if(!h){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){s={};for(const n of e.stats)s[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};k=s}}if(!s||Object.keys(s).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[a,e]of Object.entries(s)){const n=e.current||e,t=n.cpu?.percent||0,i=n.memory?.percent||0,o=$(t),d=C(i),l=n.memory?.usage||n.memory?.used||0,D=n.memory?.limit||0,O=n.network?.rxBytes||n.network?.rx||0,F=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;u+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> <div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${e.name||n}</span> <span style="font-weight: 600; flex: 1;">${e.name||a}</span>
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""} ${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span> <span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
</div> </div>
@@ -1208,32 +1179,32 @@ This will replace current configuration, credentials, and data. Containers will
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;"> <div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${t}; border-radius: 3px; transition: width 0.3s;"></div> <div style="height: 100%; width: ${Math.min(t,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${t}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${o}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span>
</div> </div>
</div> </div>
<div> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;"> <div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(r,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div> <div style="height: 100%; width: ${Math.min(i,100)}%; background: ${d}; border-radius: 3px; transition: width 0.3s;"></div>
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${r.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${d}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
</div> </div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${M(l)} / ${M(C)}</div> <div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(l)} / ${x(D)}</div>
</div> </div>
<div> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;"> <div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${M(I)}</span> <span style="color: #3498db;">\u2193 ${x(O)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span> <span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${M(F)}</span> <span style="color: #e74c3c;">\u2191 ${x(F)}</span>
</div> </div>
</div> </div>
</div> </div>
</div>`}y+="</div>",O.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(i){O.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(i.message)}</div>`}}async function T(){if(!z)return;const i=b;if(!i||Object.keys(i).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let $='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[y,n]of Object.entries(i)){const e=n.aggregated;e&&($+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}u+="</div>",P.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(s){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(s.message)}</div>`}}async function M(){if(!L)return;const s=k;if(!s||Object.keys(s).length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.aggregated;e&&(h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${n.name||y}</div> <div style="font-weight: 600; margin-bottom: 10px;">${a.name||u}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;"> <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div> <div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div> <div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
@@ -1241,94 +1212,49 @@ This will replace current configuration, credentials, and data. Containers will
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div> <div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div> </div>
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""} ${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
</div>`)}$+="</div>",z.innerHTML=$}async function j(){if(!A)return;A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const i=b;if(!i||Object.keys(i).length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let $=!1;try{$=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{$=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let n={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(n=s.configs||{})}catch{}const o=Object.entries(i).map(([t,s])=>{const l=n[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return` </div>`)}h+="</div>",L.innerHTML=h}async function j(){if(!H)return;H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const s=k;if(!s||Object.keys(s).length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.alertConfig||{};h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<tr data-container="${t}"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<td style="font-weight: 600; padding: 8px;">${s.name||t}</td> <span style="font-weight: 600; flex: 1;">${a.name||u}</span>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td> <label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td> <input type="checkbox" class="alert-enabled" data-container="${u}" ${e.enabled?"checked":""} /> Enabled
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td> </label>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${$?"":"disabled"} /></td>
<td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${t}" data-name="${s.name||t}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
</td>
</tr>
`}).join(""),a=y.map(t=>{const s=new Date(t.timestamp).toLocaleString(),l=t.notified?"\u2713":"\u2014";return`
<tr>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
<td style="padding: 6px 8px; font-weight: 500;">${t.containerName||t.containerId}</td>
<td style="padding: 6px 8px; text-transform: capitalize;">${t.metric||t.type}</td>
<td style="padding: 6px 8px;">${typeof t.value=="number"?t.value.toFixed(1):t.value}${t.metric==="disk"?" MB/s":"%"}</td>
<td style="padding: 6px 8px; text-align: center;">${l}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${t.autoRestartTriggered?"#f39c12":"var(--muted)"};">${t.autoRestartTriggered?"\u21BB":""}</td>
</tr>
`}).join(""),r=$?`
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
<a href="#" id="go-to-notifications" style="font-size: 0.8rem; color: var(--accent); text-decoration: none;">Configure notifications \u2192</a>
</div> </div>
<div style="overflow-x: auto;"> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;"> <div>
<thead> <label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
<tr style="border-bottom: 1px solid var(--border);"> <input type="number" class="alert-cpu" data-container="${u}" value="${e.cpuThreshold||80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">CPU %</th>
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Mem %</th>
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Disk I/O MB/s</th>
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">Auto-Restart</th>
<th style="padding: 6px 8px;"></th>
</tr>
</thead>
<tbody>${o}</tbody>
</table>
</div>
<div style="margin-top: 12px; display: flex; justify-content: flex-end;">
<button id="save-all-alerts" style="padding: 6px 16px; font-size: 0.85rem; background: var(--accent); color: var(--base); border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Save All</button>
</div>
</div>
`:`
<div style="margin-bottom: 20px; padding: 12px; background: rgba(241,196,15,0.1); border: 1px solid rgba(241,196,15,0.3); border-radius: 8px; text-align: center;">
<span style="color: #f1c40f; font-weight: 600; font-size: 0.85rem;">\u2B50 Premium Feature</span>
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
</div>
`;A.innerHTML=`
${r}
<div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
${a?`
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
<thead>
<tr style="border-bottom: 1px solid var(--border);">
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Time</th>
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Metric</th>
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Value</th>
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">\u2713?</th>
<th style="padding: 6px 8px;"></th>
</tr>
</thead>
<tbody>${a}</tbody>
</table>
</div> </div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'} <div>
</div> <label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;t[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),C=document.getElementById("save-all-alerts");C.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{C.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),h.classList.remove("show"),R(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.textContent;t.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${t.dataset.container}/test`,{method:"POST"}),t.textContent="\u2705",showNotification("Test alert sent for "+t.dataset.name,"success",3e3)}catch{t.textContent="\u274C"}setTimeout(()=>{t.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),R(),typeof openLicenseModal=="function"&&openLicenseModal()})}function H(){L&&clearInterval(L),N?.checked&&(L=setInterval(S,DC.POLL.STATS))}function R(){L&&(clearInterval(L),L=null)}E?.addEventListener("click",()=>{h.classList.add("show"),S(),H()}),P?.addEventListener("click",()=>{h.classList.remove("show"),R()}),h?.addEventListener("click",i=>{i.target===h&&(h.classList.remove("show"),R())}),w?.addEventListener("click",S),N?.addEventListener("change",()=>{N.checked?H():R()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),g=document.querySelectorAll(".stats-range-btn");let u="1h";function f(i){switch(i){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 m(i){return i==="raw"?"live (10s samples)":i==="hourly"?"hourly average":i==="daily"?"daily average":i}function p(i,$,y,n,e){if(!i||i.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const o=i.map($).filter(G=>G!=null);if(o.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const a=Math.max(...o,1),r=Math.min(...o,0),t=a-r||1,s=600,l=80,C=4,I=(s-C*2)/Math.max(o.length-1,1),F=o.map((G,J)=>{const X=C+J*I,Q=l-C-(G-r)/t*(l-C*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=o[o.length-1],U=o.reduce((G,J)=>G+J,0)/o.length;return` <input type="number" class="alert-mem" data-container="${u}" value="${e.memoryThreshold||85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
</div>
<div>
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
<input type="number" class="alert-cooldown" data-container="${u}" value="${e.cooldownMinutes||15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
</div>
</div>
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
<input type="checkbox" class="alert-autorestart" data-container="${u}" ${e.autoRestart?"checked":""} /> Auto-restart on breach
</label>
<span style="flex: 1;"></span>
<button class="alert-save-btn" data-container="${u}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
</div>
</div>`}h+="</div>",H.innerHTML=h,H.querySelectorAll(".alert-save-btn").forEach(u=>{u.addEventListener("click",async()=>{const a=u.dataset.container,e=H.querySelector(`.alert-enabled[data-container="${a}"]`)?.checked||!1,n=parseInt(H.querySelector(`.alert-cpu[data-container="${a}"]`)?.value)||80,t=parseInt(H.querySelector(`.alert-mem[data-container="${a}"]`)?.value)||85,i=parseInt(H.querySelector(`.alert-cooldown[data-container="${a}"]`)?.value)||15,o=H.querySelector(`.alert-autorestart[data-container="${a}"]`)?.checked||!1;try{const l=await(await secureFetch(`/api/v1/monitoring/alerts/${a}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:n,memoryThreshold:t,cooldownMinutes:i,autoRestart:o})})).json();u.textContent=l.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{u.textContent="Save"},2e3)}catch{u.textContent="\u274C Error",setTimeout(()=>{u.textContent="Save"},2e3)}})})}function B(){I&&clearInterval(I),T?.checked&&(I=setInterval(R,DC.POLL.STATS))}function A(){I&&(clearInterval(I),I=null)}E?.addEventListener("click",()=>{b.classList.add("show"),R(),B()}),N?.addEventListener("click",()=>{b.classList.remove("show"),A()}),b?.addEventListener("click",s=>{s.target===b&&(b.classList.remove("show"),A())}),S?.addEventListener("click",R),T?.addEventListener("change",()=>{T.checked?B():A()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",M),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const w=document.getElementById("stats-history-container"),z=document.getElementById("stats-history-container-area"),f=document.querySelectorAll(".stats-range-btn");let p="1h";function y(s){switch(s){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 v(s){return s==="raw"?"live (10s samples)":s==="hourly"?"hourly average":s==="daily"?"daily average":s}function m(s,h,u,a,e){if(!s||s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const n=s.map(h).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const t=Math.max(...n,1),i=Math.min(...n,0),o=t-i||1,d=600,l=80,D=4,O=(d-D*2)/Math.max(n.length-1,1),F=n.map((G,W)=>{const X=D+W*O,Q=l-D-(G-i)/o*(l-D*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],U=n.reduce((G,W)=>G+W,0)/n.length;return`
<div style="margin-bottom: 14px;"> <div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;"> <div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(n)}</span> <span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(a)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}</span> <span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e}</span>
</div> </div>
<svg viewBox="0 0 ${s} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;"> <svg viewBox="0 0 ${d} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${y}" stroke-width="1.5" points="${F}" /> <polyline fill="none" stroke="${u}" stroke-width="1.5" points="${F}" />
</svg> </svg>
</div> </div>
`}function d(){if(!x)return;const i=b||{},$=x.value,y=Object.entries(i);if(y.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=y.map(([n,e])=>`<option value="${escapeHtml(n)}">${escapeHtml(e.name||n)}</option>`).join(""),$&&i[$]&&(x.value=$)}async function c(){if(!D||!x)return;const i=x.value;if(!i){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const $=Date.now(),y=$-f(u);D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(i)}?startTime=${y}&endTime=${$}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const o=e.samples||[],a=e.tier||"raw";if(o.length===0){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${u}. Tier: ${m(a)}.</div>`;return}const r=a==="raw",t=r?F=>F.cpu?.percent:F=>F.cpu?.avg,s=r?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=r?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,C=r?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let I=` `}function r(){if(!w)return;const s=k||{},h=w.value,u=Object.entries(s);if(u.length===0){w.innerHTML='<option value="">No containers</option>';return}w.innerHTML=u.map(([a,e])=>`<option value="${escapeHtml(a)}">${escapeHtml(e.name||a)}</option>`).join(""),h&&s[h]&&(w.value=h)}async function c(){if(!z||!w)return;const s=w.value;if(!s){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const h=Date.now(),u=h-y(p);z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(s)}?startTime=${u}&endTime=${h}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],t=e.tier||"raw";if(n.length===0){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${p}. Tier: ${v(t)}.</div>`;return}const i=t==="raw",o=i?F=>F.cpu?.percent:F=>F.cpu?.avg,d=i?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=i?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,D=i?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let O=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;"> <div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${o.length} samples \xB7 ${escapeHtml(m(a))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date($).toLocaleString()} ${n.length} samples \xB7 ${escapeHtml(v(t))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(h).toLocaleString()}
</div> </div>
`;I+=p(o,t,"#2ecc71","CPU","%"),I+=p(o,s,"#3498db","Memory","%"),I+=p(o,l,"#9b59b6","Network RX"," MB"),I+=p(o,C,"#e67e22","Network TX"," MB"),D.innerHTML=I}catch(n){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(n.message)}</div>`}}g.forEach(i=>{i.addEventListener("click",()=>{g.forEach($=>$.classList.remove("active")),i.classList.add("active"),u=i.dataset.range,c()})}),x?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{d(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal"> `;O+=m(n,o,"#2ecc71","CPU","%"),O+=m(n,d,"#3498db","Memory","%"),O+=m(n,l,"#9b59b6","Network RX"," MB"),O+=m(n,D,"#e67e22","Network TX"," MB"),z.innerHTML=O}catch(a){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(a.message)}</div>`}}f.forEach(s=>{s.addEventListener("click",()=>{f.forEach(h=>h.classList.remove("active")),s.classList.add("active"),p=s.dataset.range,c()})}),w?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3> <h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1414,24 +1340,24 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button> <button id="health-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),P=document.getElementById("health-cancel"),w=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),O=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),b=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save");let B=null;function S(g){return g>=99.9?"var(--ok-fg)":g>=95?"#f39c12":"var(--bad-fg)"}function T(g){const u={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${u[g]||"var(--muted)"}20; color: ${u[g]||"var(--muted)"};">${g}</span>`}async function j(){try{const u=await(await fetch("/api/v1/health-checks/status")).json();if(!u.success||!u.status||Object.keys(u.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const f=Object.values(u.status);let m='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',m+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',m+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',m+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const p of f){const d=p.status==="up",c=d?"var(--dot-ok)":"var(--dot-bad)",i=p.uptime?.["24h"]??"-",$=p.uptime?.["7d"]??"-",y=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",n=p.timestamp?timeAgo(p.timestamp):"-";m+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(p.serviceId)}">`,m+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,m+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${d?"Up":"Down"}</td>`,m+=`<td style="padding: 8px; color: ${typeof i=="number"?S(i):"var(--muted)"};">${typeof i=="number"?i.toFixed(1)+"%":i}</td>`,m+=`<td style="padding: 8px; color: ${typeof $=="number"?S($):"var(--muted)"};">${typeof $=="number"?$.toFixed(1)+"%":$}</td>`,m+=`<td style="padding: 8px;">${y}</td>`,m+=`<td style="padding: 8px; color: var(--muted);">${n}</td>`,m+="</tr>",m+=`<tr id="health-detail-${escapeHtml(p.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}m+="</table>",N.innerHTML=m,A.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(p=>{p.addEventListener("click",async()=>{const d=p.dataset.healthId,c=document.getElementById("health-detail-"+d);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const $=await(await fetch(`/api/v1/health-checks/${d}/stats?hours=24`)).json();if($.success&&$.stats){const y=$.stats,n=y.responseTime||{};c.querySelector("td").innerHTML=` </div>`);const b=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),N=document.getElementById("health-cancel"),S=document.getElementById("health-refresh-btn"),T=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),L=document.getElementById("health-config-container"),H=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),I=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),$=document.getElementById("health-form-save");let C=null;function R(f){return f>=99.9?"var(--ok-fg)":f>=95?"#f39c12":"var(--bad-fg)"}function M(f){const p={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${p[f]||"var(--muted)"}20; color: ${p[f]||"var(--muted)"};">${f}</span>`}async function j(){try{const p=await(await fetch("/api/v1/health-checks/status")).json();if(!p.success||!p.status||Object.keys(p.status).length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const y=Object.values(p.status);let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',v+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',v+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',v+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of y){const r=m.status==="up",c=r?"var(--dot-ok)":"var(--dot-bad)",s=m.uptime?.["24h"]??"-",h=m.uptime?.["7d"]??"-",u=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",a=m.timestamp?timeAgo(m.timestamp):"-";v+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px; color: ${typeof s=="number"?R(s):"var(--muted)"};">${typeof s=="number"?s.toFixed(1)+"%":s}</td>`,v+=`<td style="padding: 8px; color: ${typeof h=="number"?R(h):"var(--muted)"};">${typeof h=="number"?h.toFixed(1)+"%":h}</td>`,v+=`<td style="padding: 8px;">${u}</td>`,v+=`<td style="padding: 8px; color: var(--muted);">${a}</td>`,v+="</tr>",v+=`<tr id="health-detail-${escapeHtml(m.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}v+="</table>",T.innerHTML=v,H.textContent="Updated "+new Date().toLocaleTimeString(),T.querySelectorAll("tr[data-health-id]").forEach(m=>{m.addEventListener("click",async()=>{const r=m.dataset.healthId,c=document.getElementById("health-detail-"+r);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const h=await(await fetch(`/api/v1/health-checks/${r}/stats?hours=24`)).json();if(h.success&&h.stats){const u=h.stats,a=u.responseTime||{};c.querySelector("td").innerHTML=`
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;"> <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${y.totalChecks||0}</strong></div> <div><span style="color: var(--muted);">Total Checks</span><br><strong>${u.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${S(y.uptime||0)};">${(y.uptime||0).toFixed(2)}%</strong></div> <div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${R(u.uptime||0)};">${(u.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(n.avg||0)}ms</strong></div> <div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(a.avg||0)}ms</strong></div>
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(n.p95||0)}ms / ${Math.round(n.p99||0)}ms</strong></div> <div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(a.p95||0)}ms / ${Math.round(a.p99||0)}ms</strong></div>
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(n.min||0)}ms</strong></div> <div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(a.min||0)}ms</strong></div>
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(n.max||0)}ms</strong></div> <div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(a.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${y.upChecks||0}</strong></div> <div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${u.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${y.downChecks||0}</strong></div> <div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${u.downChecks||0}</strong></div>
</div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(i){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}})})}catch(g){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(g.message)}</div>`}}async function H(){try{const[g,u]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),f=await g.json(),m=await u.json();let p="";const d=f.success&&f.incidents?f.incidents:[];if(d.length>0){p+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+d.length+")</h4>";for(const i of d)p+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);"> </div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(s){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`}}})})}catch(f){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(f.message)}</div>`}}async function B(){try{const[f,p]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),y=await f.json(),v=await p.json();let m="";const r=y.success&&y.incidents?y.incidents:[];if(r.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+r.length+")</h4>";for(const s of r)m+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(i.serviceId)}</span> <span style="font-weight: 500;">${escapeHtml(s.serviceId)}</span>
<span>${T(i.severity)}</span> <span>${M(s.severity)}</span>
</div> </div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(i.message)}</div> <div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(s.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(i.createdAt)} \xB7 ${i.occurrences||1} occurrence(s)</div> <div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(s.createdAt)} \xB7 ${s.occurrences||1} occurrence(s)</div>
</div>`;p+="</div>"}else p+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const c=m.success&&m.history?m.history:[];if(c.length>0){p+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',p+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const i of c){const $=i.status==="resolved",y=$&&i.duration?i.duration<6e4?Math.round(i.duration/1e3)+"s":Math.round(i.duration/6e4)+"m":"-";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 6px;">${escapeHtml(i.serviceId)}</td>`,p+=`<td style="padding: 6px;">${escapeHtml(i.type)}</td>`,p+=`<td style="padding: 6px;">${T(i.severity)}</td>`,p+=`<td style="padding: 6px;"><span style="color: ${$?"var(--ok-fg)":"var(--bad-fg)"};">${i.status}</span></td>`,p+=`<td style="padding: 6px;">${y}</td>`,p+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(i.createdAt)}</td>`,p+="</tr>"}p+="</table>"}O.innerHTML=p||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(g){O.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}async function R(){try{const u=await(await fetch("/api/v1/health-checks/status")).json(),f=u.success&&u.status?Object.values(u.status):[];if(f.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let m='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const p of f){const d=p.status==="up";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,m+=`<td style="padding: 8px; color: ${d?"var(--ok-fg)":"var(--bad-fg)"};">${d?"Up":"Down"}</td>`,m+=`<td style="padding: 8px;">${p.sla?.target?p.sla.target+"%":"-"}</td>`,m+='<td style="padding: 8px; text-align: right;">',m+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,m+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,m+="</td></tr>"}m+="</table>",z.innerHTML=m}catch(g){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}function x(g,u,f,m,p,d,c){B=g||null,b.textContent=g?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=g||"",document.getElementById("health-form-id").disabled=!!g,document.getElementById("health-form-name").value=u||"",document.getElementById("health-form-url").value=f||"",document.getElementById("health-form-timeout").value=m||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=d||99.9,document.getElementById("health-form-slow").value=c||5e3,L.style.display="",v.style.display="none"}function D(){L.style.display="none",v.style.display="",B=null}v?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",D),k?.addEventListener("click",async()=>{const g=B||document.getElementById("health-form-id").value.trim();if(!g)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 f=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),m={name:document.getElementById("health-form-name").value.trim()||g,url:u,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:f.length?f:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated </div>`;m+="</div>"}else m+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const c=v.success&&v.history?v.history:[];if(c.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const s of c){const h=s.status==="resolved",u=h&&s.duration?s.duration<6e4?Math.round(s.duration/1e3)+"s":Math.round(s.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(s.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(s.type)}</td>`,m+=`<td style="padding: 6px;">${M(s.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${h?"var(--ok-fg)":"var(--bad-fg)"};">${s.status}</span></td>`,m+=`<td style="padding: 6px;">${u}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(s.createdAt)}</td>`,m+="</tr>"}m+="</table>"}P.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(f){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}async function A(){try{const p=await(await fetch("/api/v1/health-checks/status")).json(),y=p.success&&p.status?Object.values(p.status):[];if(y.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const m of y){const r=m.status==="up";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px; color: ${r?"var(--ok-fg)":"var(--bad-fg)"};">${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,v+='<td style="padding: 8px; text-align: right;">',v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,v+="</td></tr>"}v+="</table>",L.innerHTML=v}catch(f){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}function w(f,p,y,v,m,r,c){C=f||null,k.textContent=f?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=f||"",document.getElementById("health-form-id").disabled=!!f,document.getElementById("health-form-name").value=p||"",document.getElementById("health-form-url").value=y||"",document.getElementById("health-form-timeout").value=v||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=r||99.9,document.getElementById("health-form-slow").value=c||5e3,I.style.display="",g.style.display="none"}function z(){I.style.display="none",g.style.display="",C=null}g?.addEventListener("click",()=>w("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",z),$?.addEventListener("click",async()=>{const f=C||document.getElementById("health-form-id").value.trim();if(!f)return showNotification("Service ID is required","warning");const p=document.getElementById("health-form-url").value.trim();if(!p)return showNotification("URL is required","warning");const y=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),v={name:document.getElementById("health-form-name").value.trim()||f,url:p,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)|Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3> <h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1447,10 +1373,8 @@ This will replace current configuration, credentials, and data. Containers will
<!-- Tab: Available Updates --> <!-- Tab: Available Updates -->
<div id="updates-available" class="panel-section active"> <div id="updates-available" class="panel-section active">
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;"> <div style="margin-bottom: 12px;">
<button id="updates-check-btn" class="btn-accent-solid">\u{1F50D} Check for Updates</button> <button id="updates-check-btn" class="btn-accent-solid">\u{1F50D} Check for Updates</button>
<button id="updates-update-all-btn" style="display: none; padding: 6px 14px; font-size: 0.82rem; background: #f97316; color: #fff; border: 1px solid #f97316; border-radius: 6px; cursor: pointer;">\u2B06\uFE0F Update All</button>
<span id="updates-count-badge" style="display: none; padding: 4px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; background: var(--accent); color: var(--bg);"></span>
</div> </div>
<div id="updates-available-container" style="max-height: 450px; overflow-y: auto;"> <div id="updates-available-container" style="max-height: 450px; overflow-y: auto;">
<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span> Click "Check for Updates" to scan containers.</div> <div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span> Click "Check for Updates" to scan containers.</div>
@@ -1505,17 +1429,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button> <button id="updates-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),P=document.getElementById("updates-cancel"),w=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),O=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function v(){try{const n=await(await fetch("/api/v1/updates/available")).json();if(!n.success)throw new Error(n.error);const e=n.updates||[];if(e.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',A.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const t of e){const s=(()=>{const l=window.APPS||[];for(const C of l)if(C.containerId===t.containerId||C.name===t.containerName||C.id===t.containerName)return C.id;return t.containerName})();o+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,o+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,o+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.latestDigest)}</code></td>`,o+='<td style="padding: 8px; text-align: right;">',o+=`<button class="update-now-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,o+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,o+="</td></tr>"}o+="</table>",N.innerHTML=o,A.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),r=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),r&&e.length>0&&(r.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.dataset.id,l=t.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(I.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Update failed")}catch(C){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+C.message,"error"),setTimeout(()=>{t.textContent="Update",t.disabled=!1,t.style.color="",t.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.dataset.id,l=t.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(I.success)t.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Rollback failed")}catch(C){t.textContent="Failed",showNotification("Rollback error: "+C.message,"error"),setTimeout(()=>{t.textContent="Rollback",t.disabled=!1},3e3)}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function L(){const y=window._pendingUpdates||[];if(!y.length)return;const n=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;n.textContent="\u23F3 Updating...",n.disabled=!0;let e=0,o=0;for(const a of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:o++}catch{o++}n.textCLine truncated </div>`);const b=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),N=document.getElementById("updates-cancel"),S=document.getElementById("updates-check-btn"),T=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),L=document.getElementById("updates-auto-container"),H=document.getElementById("updates-last-check");async function g(){try{const u=await(await fetch("/api/v1/updates/available")).json();if(!u.success)throw new Error(u.error);const a=u.updates||[];if(a.length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',H.textContent="";return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of a)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",T.innerHTML=e,H.textContent=a.length+" update(s) available",T.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Update "${i}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(d.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Update failed")}catch(o){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+o.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),T.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Rollback "${i}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(d.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Rollback failed")}catch(o){n.textContent="Failed",showNotification("Rollback error: "+o.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(h){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}async function I(){S.textContent="\u{1F50D} Checking...",S.disabled=!0;try{const u=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!u.success)throw new Error(u.error);S.textContent="\u2705 Done!",await g()}catch(h){S.textContent="\u274C Failed",showNotification("Check error: "+h.message,"error")}setTimeout(()=>{S.textContent="\u{1F50D} Check for Updates",S.disabled=!1},3e3)}async function k(){try{P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const u=await(await fetch("/api/v1/updates/history?limit=50")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
<select class="auto-schedule" data-id="${escapeHtml(C)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;"> <select class="auto-schedule" data-id="${escapeHtml(l)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${F?"":" selected"}>Disabled</option> <option value=""${O?"":" selected"}>Disabled</option>
<option value="daily"${F==="daily"?" selected":""}>Daily</option> <option value="daily"${O==="daily"?" selected":""}>Daily</option>
<option value="weekly"${F==="weekly"?" selected":""}>Weekly</option> <option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${F==="monthly"?" selected":""}>Monthly</option> <option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
</select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(C)}" value="${escapeHtml(U)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,t+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(C)}"${q?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(C)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",z.innerHTML=t,z.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,C=s.closest("tr"),I=C.querySelector(".auto-schedule").value,F=C.querySelector(".auto-rollback").checked,q=C.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!I,schedule:I||"weekly",autoRollback:F,maintenanceWindow:q||void 0})})).json();if(G.success)s.textContent="\u2713 Saved";else throw new Error(G.error)}catch(U){s.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}const B=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),H=document.getElementById("dashcaddy-changelog"),R=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),g=document.getElementById("dashcaddy-status-bar"),u=document.getElementById("dashcaddy-history-container");let f=null;function m(y,n){g&&(g.style.display="block",g.style.background=n==="error"?"var(--bad-bg)":n==="success"?"var(--ok-bg)":"var(--bg)",g.style.color=n==="error"?"var(--bad-fg)":n==="success"?"var(--ok-fg)":"var(--fg)",g.textContent=y)}async function p(){try{const n=await(await fetch("/api/v1/system/version")).json();if(n.success){const e=n.commit&&n.commit!=="unknown"?n.commit:null;B.textContent="v"+n.version+(e?" ("+e.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function d(y){y||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(f=e,e.success&&e.available&&e.remote){S.style.display="",T.style.display="",j.textContent="v"+e.remote.version,H.textContent=e.remote.changelog||"No changelog available.";const o=document.getElementById("updates-btn");if(o&&!o.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",o.style.position="relative",o.appendChild(r)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(r)}}else S.style.display="none",T.style.display="none",await p(),y||m("You are running the latest version.","success");y||(x.textContent="Check for Updates",x.disabled=!1)}catch(n){y||(m("Failed to check: "+n.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;R.textContent="Updating...",R.disabled=!0,m("Downloading and applying update...","info");try{const n=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(n.success)return m("Update initiated: v"+(n.fromVersion||"?")+" \u2192 v"+(n.toVersion||"?")+". The container will restart shortly.","success"),R.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(n.error||"Update failed")}catch(y){throw m("Update failed: "+y.message,"error"),R.textContent="Update Now",R.disabled=!1,y}}async function i(){try{const n=await(await fetch("/api/v1/system/update-history")).json(),e=n.success&&n.history?n.history:[];if(e.length===0){u.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated </select></td>`,i+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(l)}" value="${escapeHtml(q)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,i+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(l)}"${F?" checked":""} /></td>`,i+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,i+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(l)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,i+="</tr>"}i+="</table>",L.innerHTML=i,L.querySelectorAll(".save-auto-btn").forEach(o=>{o.addEventListener("click",async()=>{const d=o.dataset.id,l=o.closest("tr"),D=l.querySelector(".auto-schedule").value,O=l.querySelector(".auto-rollback").checked,F=l.querySelector(".auto-window").value.trim();o.textContent="Saving...",o.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!D,schedule:D||"weekly",autoRollback:O,maintenanceWindow:F||void 0})})).json();if(U.success)o.textContent="\u2713 Saved";else throw new Error(U.error)}catch(q){o.textContent="\u2717 Error",showNotification("Save error: "+q.message,"error")}setTimeout(()=>{o.textContent="Save",o.disabled=!1},2e3)})})}catch(h){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),R=document.getElementById("dashcaddy-update-details"),M=document.getElementById("dashcaddy-new-version"),j=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),A=document.getElementById("dashcaddy-check-btn"),w=document.getElementById("dashcaddy-rollback-btn"),z=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let p=null;function y(h,u){z&&(z.style.display="block",z.style.background=u==="error"?"var(--bad-bg)":u==="success"?"var(--ok-bg)":"var(--bg)",z.style.color=u==="error"?"var(--bad-fg)":u==="success"?"var(--ok-fg)":"var(--fg)",z.textContent=h)}async function v(){try{const u=await(await fetch("/api/v1/system/version")).json();if(u.success){const a=u.commit&&u.commit!=="unknown"?u.commit:null;$.textContent="v"+u.version+(a?" ("+a.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function m(h){h||(A.textContent="Checking...",A.disabled=!0);try{const a=await(await fetch("/api/v1/system/update-check")).json();if(p=a,a.success&&a.available&&a.remote){C.style.display="",R.style.display="",M.textContent="v"+a.remote.version,j.textContent=a.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 n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.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;",n.appendChild(t)}}else C.style.display="none",R.style.display="none",await v(),h||y("You are running the latest version.","success");h||(A.textContent="Check for Updates",A.disabled=!1)}catch(u){h||(y("Failed to check: "+u.message,"error"),A.textContent="Check for Updates",A.disabled=!1)}}async function r(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,y("Downloading and applying update...","info");try{const u=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(u.success)return y("Update initiated: v"+(u.fromVersion||"?")+" \u2192 v"+(u.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(a=>a.remove()),!0;throw new Error(u.error||"Update failed")}catch(h){throw y("Update failed: "+h.message,"error"),B.textContent="Update Now",B.disabled=!1,h}}async function c(){try{const u=await(await fetch("/api/v1/system/update-history")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+e.join(` `+a.join(`
`)+` `)+`
Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification("Invalid version: "+o,"error");return}if(!confirm("Rollback DashCaddy to v"+o+"? The container will restart."))return;m("Rolling back to v"+o+"...","info");const r=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:o})})).json();if(r.success)m("Rollback to v"+o+" initiated. Container will restart.","success");else throw new Error(r.error||"Rollback failed")}catch(y){m("Rollback failed: "+y.message,"error")}}x?.addEventListener("click",()=>d(!1)),R?.addEventListener("click",()=>c().catch(()=>{})),D?.addEventListener("click",$),w?.addEventListener("click",b),E?.addEventListener("click",()=>{h?.classList.add("show"),v()}),wireModal(h,P),window.openUpdateModal=function(y){h?.classList.add("show"),v().then(()=>{if(!y)return;const n=N.querySelector(`[data-app-id="${y}"]`);n&&(n.scrollIntoView({behavior:"smooth",block:"center"}),n.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{n.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",M),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{p(),i(),f||d(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=d,setTimeout(()=>d(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal"> Enter version to rollback to:`);if(!e)return;if(!a.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(h){y("Rollback failed: "+h.message,"error")}}A?.addEventListener("click",()=>m(!1)),B?.addEventListener("click",()=>r().catch(()=>{})),w?.addEventListener("click",s),S?.addEventListener("click",I),E?.addEventListener("click",()=>{b?.classList.add("show"),g()}),wireModal(b,N),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),c(),p||m(!0)}),window.dcApplyUpdate=r,window.dcCheckForUpdate=m,setTimeout(()=>m(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3> <h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p> <p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1564,7 +1488,7 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
<button id="dr-close">Close</button> <button id="dr-close">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),P=document.getElementById("dr-close");function w(A){if(!A||A===0)return"0 B";const v=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(A))/Math.log(1024));return(A/Math.pow(1024,L)).toFixed(1)+" "+v[L]}async function N(){const A=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let b='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';b+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of L){const k=M.name==="buildkit"||M.name.length===64;b+='<tr style="border-bottom: 1px solid var(--border);">',b+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(M.name)}">${escapeHtml(M.name.length>40?M.name.substring(0,37)+"...":M.name)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,b+='<td style="padding: 6px; text-align: right;">',k||(b+=`<button class="dr-vol-del" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),b+="</td></tr>"}b+="</table>",A.innerHTML=b,A.querySelectorAll(".dr-vol-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete volume "${M.dataset.name}"? Data will be lost.`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(M.dataset.name)}?force=true`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-vol-name"),v=A.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),A.value="",showNotification(`Volume "${v}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function O(){const A=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let b='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';b+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of L){const k=["bridge","host","none"].includes(M.name);b+='<tr style="border-bottom: 1px solid var(--border);">',b+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(M.name)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,b+=`<td style="padding: 6px; text-align: center;">${M.containers}</td>`,b+='<td style="padding: 6px; text-align: right;">',k||(b+=`<button class="dr-net-del" data-id="${escapeHtml(M.id)}" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),b+="</td></tr>"}b+="</table>",A.innerHTML=b,A.querySelectorAll(".dr-net-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete network "${M.dataset.name}"?`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(M.dataset.id)}`),O()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),L=A.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:v.value}),A.value="",showNotification(`Network "${L}" created`,"success"),O()}catch(b){showNotification("Create failed: "+b.message,"error")}});async fuLine truncated </div>`);const b=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),N=document.getElementById("dr-close");function S(H){if(!H||H===0)return"0 B";const g=["B","KB","MB","GB","TB"],I=Math.floor(Math.log(Math.abs(H))/Math.log(1024));return(H/Math.pow(1024,I)).toFixed(1)+" "+g[I]}async function T(){const H=document.getElementById("dr-vol-list");try{const I=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of I){const $=x.name==="buildkit"||x.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.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`),T()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-vol-name"),g=H.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),H.value="",showNotification(`Volume "${g}" created`,"success"),T()}catch(I){showNotification("Create failed: "+I.message,"error")}});async function P(){const H=document.getElementById("dr-net-list");try{const I=(await getJSON("/api/v1/docker/networks")).networks||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of I){const $=["bridge","host","none"].includes(x.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-net-del" data-id="${escapeHtml(x.id)}" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.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($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),I=H.value.trim();if(!I){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:I,driver:g.value}),H.value="",showNotification(`Network "${I}" created`,"success"),P()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;"> <div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3> <h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p> <p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1605,8 +1529,8 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
<div id="compose-progress-content"></div> <div id="compose-progress-content"></div>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),P=document.getElementById("compose-cancel");wireModal(h,P);let w=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}E?.addEventListener("click",()=>{N("paste"),w=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const A=z.target.files[0];if(!A)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(A)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),A=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),L=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const b=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:A});w=b,w.stackName=A,O(b),N("preview")}catch(b){showNotification("Parse failed: "+b.message,"error")}finally{v.textContent=L,v.disabled=!1}});function O(z){const A=document.getElementById("compose-preview-content");let v="";z.networks&&z.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const L of z.services){const b=L.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${b}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),v+="</div>",!L.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(M=>`${M.host}:${M.container}`).join(", ")}</div>`),L.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const M=[];L.resources.cpus&&M.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&M.push(`Mem: ${L.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${M.join(", ")}</div>`}v+="</div>"}v+="</div>",A.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!w)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const A=document.getElementById("compose-progress-content");A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:w.services,networks:w.networks,stackName:w.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const b of v.results){const M=b.status==="deployed"||b.status==="created"?"\u2705":b.status==="exists"?"\u26A1":b.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${M} <strong>${escapeHtml(b.name)}</strong> (${b.type}) \u2014 ${escapeHtml(b.status)}`,b.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(b.error)}</span>`),b.subdomain&&(L+=` \u2192 <code>${escapeHtml(b.subdomain)}</code>`),b.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(b.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated </div>`);const b=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),N=document.getElementById("compose-cancel");wireModal(b,N);let S=null;function T(L){document.getElementById("compose-step-paste").style.display=L==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=L==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=L==="progress"?"":"none"}E?.addEventListener("click",()=>{T("paste"),S=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",L=>{const H=L.target.files[0];if(!H)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(H)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const L=document.getElementById("compose-yaml").value.trim(),H=document.getElementById("compose-stack-name").value.trim()||"stack";if(!L){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),I=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:L,stackName:H});S=k,S.stackName=H,P(k),T("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{g.textContent=I,g.disabled=!1}});function P(L){const H=document.getElementById("compose-preview-content");let g="";L.networks&&L.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${L.networks.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),L.volumes&&L.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${L.volumes.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${L.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const I of L.services){const k=I.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(I.name)}`,I.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(I.reason)}</span>`),g+="</div>",!I.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(I.image)}</code></div>`,I.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${I.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),I.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${I.volumes.length}</div>`),Object.keys(I.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(I.environment).length}</div>`),I.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(I.envFileWarning)}</div>`),I.resources?.cpus||I.resources?.memory)){const x=[];I.resources.cpus&&x.push(`CPU: ${I.resources.cpus}`),I.resources.memory&&x.push(`Mem: ${I.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}g+="</div>"}g+="</div>",H.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>T("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!S)return;const L=document.getElementById("compose-deploy-btn");L.textContent="Deploying...",L.disabled=!0,T("progress");const H=document.getElementById("compose-progress-content");H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:S.services,networks:S.networks,stackName:S.stackName});let I=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;I+='<div class="scroll-container" style="max-height: 350px;">';for(const k of g.results){const x=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";I+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',I+=`${x} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&(I+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&(I+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&(I+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),I+="</div>"}I+="</div>",I+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal"> <div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>T("paste"))}finally{L.textContent="Deploy All",L.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;"> <div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3> <h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div> <div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
@@ -1614,11 +1538,11 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
<button id="exec-close">Close</button> <button id="exec-close">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),P=document.getElementById("exec-close");let w=null,N=null,O=null;function z(){if(N){try{N.close()}catch{}N=null}if(w){try{w.dispose()}catch{}w=null}O=null,E.innerHTML=""}function A(v,L){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||v}`,h?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}w=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"&&(O=new FitAddon.FitAddon,w.loadAddon(O)),w.open(E),O&&setTimeout(()=>O.fit(),50);const b=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${b}//${location.host}/ws/exec/${encodeURIComponent(v)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(w.writeln("\x1B[32mConnecting...\x1B[0m"),O){const k=O.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const B=JSON.parse(k.data);if(B.type==="connected"){w.writeln(`\x1B[32mConnected (${B.shell})\x1B[0m\r </div>`);const b=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),N=document.getElementById("exec-close");let S=null,T=null,P=null;function L(){if(T){try{T.close()}catch{}T=null}if(S){try{S.dispose()}catch{}S=null}P=null,E.innerHTML=""}function H(g,I){if(L(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${I||g}`,b?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}S=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,S.loadAddon(P)),S.open(E),P&&setTimeout(()=>P.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";T=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(g)}`),T.binaryType="arraybuffer",T.onopen=()=>{if(S.writeln("\x1B[32mConnecting...\x1B[0m"),P){const $=P.proposeDimensions();$&&T.send(JSON.stringify({type:"resize",cols:$.cols,rows:$.rows}))}},T.onmessage=$=>{if(typeof $.data=="string"){try{const C=JSON.parse($.data);if(C.type==="connected"){S.writeln(`\x1B[32mConnected (${C.shell})\x1B[0m\r
`);return}if(B.type==="error"){w.writeln(`\x1B[31mError: ${B.message}\x1B[0m`);return}if(B.type==="exit"){w.writeln(`\r `);return}if(C.type==="error"){S.writeln(`\x1B[31mError: ${C.message}\x1B[0m`);return}if(C.type==="exit"){S.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}w.write(k.data)}else w.write(new Uint8Array(k.data))},N.onclose=()=>{w&&w.writeln(`\r \x1B[33mSession ended.\x1B[0m`);return}}catch{}S.write($.data)}else S.write(new Uint8Array($.data))},T.onclose=()=>{S&&S.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{w&&w.writeln(`\r \x1B[33mDisconnected.\x1B[0m`)},T.onerror=()=>{S&&S.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},w.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),w.onResize(({cols:k,rows:B})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:B}))});const M=()=>{O&&O.fit()};window.addEventListener("resize",M),h._resizeHandler=M}P?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",v=>{v.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=A})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal"> \x1B[31mConnection error.\x1B[0m`)},S.onData($=>{T&&T.readyState===WebSocket.OPEN&&T.send($)}),S.onResize(({cols:$,rows:C})=>{T&&T.readyState===WebSocket.OPEN&&T.send(JSON.stringify({type:"resize",cols:$,rows:C}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),b._resizeHandler=x}N?.addEventListener("click",()=>{L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",g=>{g.target===b&&(L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=H})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;"> <div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
<h3>\u{1F4DC} Audit Log</h3> <h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1654,7 +1578,7 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
<button id="audit-cancel">Close</button> <button id="audit-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),P=document.getElementById("audit-cancel"),w=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),O=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let v=0;const L=50;async function b(M){try{M||(v=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=O.value;let B=`/api/v1/audit-logs?limit=${L}&offset=${v}`;k&&(B+=`&action=${encodeURIComponent(k)}`);const T=await(await fetch(B)).json(),j=T.success&&T.entries?T.entries:[];if(j.length===0&&!M){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',A.style.display="none";return}let H="";M||(H='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',H+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const R of j){const x=R.outcome==="success";H+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',H+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(R.timestamp)}</td>`,H+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(R.ip||"-")}</td>`,H+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(R.action||"-")}</td>`,H+=`<td style="padding: 6px;">${escapeHtml(R.resource||"-")}</td>`,H+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,H+="</tr>",R.details&&Object.keys(R.details).length>0&&(H+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(R.details,null,2))}</pre></td></tr>`)}if(!M)H+="</table>",z.innerHTML=H;else{const R=z.querySelector("table");R&&R.insertAdjacentHTML("beforeend",H)}v+=j.length,A.style.display=j.length>=L?"":"none",z.querySelectorAll(".audit-row").forEach(R=>{R.dataset.wired||(R.dataset.wired="true",R.addEventListener("click",()=>{const x=R.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}E?.addEventListener("click",()=>{h?.classList.add("show"),b(!1)}),wireModal(h,P),w?.addEventListener("click",()=>b(!1)),O?.addEventListener("change",()=>b(!1)),A?.addEventListener("click",()=>b(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?b(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch(M){showNotification("Error: "+M.message,"error")}})})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3> </div>`);const b=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),N=document.getElementById("audit-cancel"),S=document.getElementById("audit-refresh-btn"),T=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),L=document.getElementById("audit-log-container"),H=document.getElementById("audit-load-more");let g=0;const I=50;async function k(x){try{x||(g=0,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const $=P.value;let C=`/api/v1/audit-logs?limit=${I}&offset=${g}`;$&&(C+=`&action=${encodeURIComponent($)}`);const M=await(await fetch(C)).json(),j=M.success&&M.entries?M.entries:[];if(j.length===0&&!x){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',H.style.display="none";return}let B="";x||(B='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',B+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const A of j){const w=A.outcome==="success";B+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',B+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(A.timestamp)}</td>`,B+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(A.ip||"-")}</td>`,B+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(A.action||"-")}</td>`,B+=`<td style="padding: 6px;">${escapeHtml(A.resource||"-")}</td>`,B+=`<td style="padding: 6px;"><span style="color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"\u2713":"\u2717"}</span></td>`,B+="</tr>",A.details&&Object.keys(A.details).length>0&&(B+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(A.details,null,2))}</pre></td></tr>`)}if(!x)B+="</table>",L.innerHTML=B;else{const A=L.querySelector("table");A&&A.insertAdjacentHTML("beforeend",B)}g+=j.length,H.style.display=j.length>=I?"":"none",L.querySelectorAll(".audit-row").forEach(A=>{A.dataset.wired||(A.dataset.wired="true",A.addEventListener("click",()=>{const w=A.nextElementSibling;w&&w.classList.contains("audit-detail")&&(w.style.display=w.style.display==="none"?"":"none")}))})}catch($){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml($.message)}</div>`}}E?.addEventListener("click",()=>{b?.classList.add("show"),k(!1)}),wireModal(b,N),S?.addEventListener("click",()=>k(!1)),P?.addEventListener("change",()=>k(!1)),H?.addEventListener("click",()=>k(!0)),T?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const $=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();$.success?k(!1):showNotification("Error: "+($.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
<label for="weather-location-input">Location:</label> <label for="weather-location-input">Location:</label>
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100"> <input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div> <div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -1665,23 +1589,23 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label> <label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label>
</div> </div>
</div> </div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",P="weather-zip",w="weather-geo",N="weather-unit";!safeGet(E)&&safeGet(P)&&safeSet(E,safeGet(P));function O(){return safeGet(N)||"imperial"}function z(){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 A={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"},v={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"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function b(H){return L[Math.round(H/22.5)%16]}async function M(H){const R=safeGet(w);if(R)try{const f=JSON.parse(R);if(f.query===H)return f}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(H)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const D=await x.json();if(!D.results||!D.results.length)throw new Error("Location not found");const g=D.results[0],u={query:H,lat:g.latitude,lon:g.longitude,city:g.name,state:g.admin1||"",country:g.country||"",countryCode:g.country_code||""};return safeSet(w,JSON.stringify(u)),u}function k(H){return H.countryCode==="US"&&H.state?`${H.city}, ${H.state}`:H.country?`${H.city}, ${H.country}`:H.city}async function B(H){try{const R=await M(H),x=O(),D=x==="metric"?"celsius":"fahrenheit",g=x==="metric"?"kmh":"mph",u=`https://api.open-meteo.com/v1/forecast?latitude=${R.lat}&longitude=${R.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${g}`,f=await fetch(u);if(!f.ok)throw new Error("Weather fetch failed");const p=(await f.json()).current,d=p.weather_code;return{temp:Math.round(p.temperature_2m),condition:A[d]||"Unknown",icon:v[d]||"\u{1F324}\uFE0F",locationStr:k(R),windSpeed:Math.round(p.wind_speed_10m),windDir:b(p.wind_direction_10m),unit:x}}catch(R){return console.warn("Weather fetch failed:",R),null}}async function S(){const H=z();if(!H.icon||!H.temp||!H.condition||!H.location||!H.wind){console.warn("Weather widget elements not found");return}const R=safeGet(E);if(!R){H.location.textContent="Set Location",H.temp.textContent="--\xB0",H.condition.textContent="Click \u2699\uFE0F to configure",H.wind.textContent="--",H.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await B(R);if(x){const D=x.unit==="metric"?"\xB0C":"\xB0F",g=x.unit==="metric"?"km/h":"mph";H.location.textContent=x.locationStr,H.temp.textContent=`${x.temp}${D}`,H.condition.textContent=x.condition,H.wind.textContent=`Wind: ${x.windSpeed} ${g} ${x.windDir}`,H.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),H.location.textContent="Weather Error",H.temp.textContent="Error",H.condition.textContent="Failed to load",H.wind.textContent="--"}}const T=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const H=O(),R=T.querySelector(`input[name="weather-unit-radio"][value="${H}"]`);R&&(R.checked=!0),T.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const H=j.value.trim();if(H){safeGet(E)!==H&&safeSet(w,""),safeSet(E,H);const x=T.querySelector('input[name="weather-unit-radio"]:checked'),D=x?x.value:"imperial",g=O();safeSet(N,D),g!==D&&safeSet(w,""),T.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",H=>{H.keyLine truncated <div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",N="weather-zip",S="weather-geo",T="weather-unit";!safeGet(E)&&safeGet(N)&&safeSet(E,safeGet(N));function P(){return safeGet(T)||"imperial"}function L(){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 H={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},I=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(B){return I[Math.round(B/22.5)%16]}async function x(B){const A=safeGet(S);if(A)try{const y=JSON.parse(A);if(y.query===B)return y}catch{}const w=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(B)}&count=1&language=en&format=json`);if(!w.ok)throw new Error("Geocoding failed");const z=await w.json();if(!z.results||!z.results.length)throw new Error("Location not found");const f=z.results[0],p={query:B,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(S,JSON.stringify(p)),p}function $(B){return B.countryCode==="US"&&B.state?`${B.city}, ${B.state}`:B.country?`${B.city}, ${B.country}`:B.city}async function C(B){try{const A=await x(B),w=P(),z=w==="metric"?"celsius":"fahrenheit",f=w==="metric"?"kmh":"mph",p=`https://api.open-meteo.com/v1/forecast?latitude=${A.lat}&longitude=${A.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${z}&wind_speed_unit=${f}`,y=await fetch(p);if(!y.ok)throw new Error("Weather fetch failed");const m=(await y.json()).current,r=m.weather_code;return{temp:Math.round(m.temperature_2m),condition:H[r]||"Unknown",icon:g[r]||"\u{1F324}\uFE0F",locationStr:$(A),windSpeed:Math.round(m.wind_speed_10m),windDir:k(m.wind_direction_10m),unit:w}}catch(A){return console.warn("Weather fetch failed:",A),null}}async function R(){const B=L();if(!B.icon||!B.temp||!B.condition||!B.location||!B.wind){console.warn("Weather widget elements not found");return}const A=safeGet(E);if(!A){B.location.textContent="Set Location",B.temp.textContent="--\xB0",B.condition.textContent="Click \u2699\uFE0F to configure",B.wind.textContent="--",B.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const w=await C(A);if(w){const z=w.unit==="metric"?"\xB0C":"\xB0F",f=w.unit==="metric"?"km/h":"mph";B.location.textContent=w.locationStr,B.temp.textContent=`${w.temp}${z}`,B.condition.textContent=w.condition,B.wind.textContent=`Wind: ${w.windSpeed} ${f} ${w.windDir}`,B.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(w.icon)}</span>`}}catch(w){b.logError("[Weather] Update Error",w,{function:"updateWeather"}),B.location.textContent="Weather Error",B.temp.textContent="Error",B.condition.textContent="Failed to load",B.wind.textContent="--"}}const M=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const B=P(),A=M.querySelector(`input[name="weather-unit-radio"][value="${B}"]`);A&&(A.checked=!0),M.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const B=j.value.trim();if(B){safeGet(E)!==B&&safeSet(S,""),safeSet(E,B);const w=M.querySelector('input[name="weather-unit-radio"]:checked'),z=w?w.value:"imperial",f=P();safeSet(T,z),f!==z&&safeSet(S,""),M.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(M),document.addEventListener("keydown",B=>{B.keyLine truncated
<circle cx="${s}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/> <circle cx="${d}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${U} ${U}
${q} ${q}
<line x1="${s}" y1="${l}" x2="${s+28*Math.cos(F*Math.PI/180)}" y2="${l+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/> <line x1="${d}" y1="${l}" x2="${d+28*Math.cos(F*Math.PI/180)}" y2="${l+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+38*Math.cos(I*Math.PI/180)}" y2="${l+38*Math.sin(I*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/> <line x1="${d}" y1="${l}" x2="${d+38*Math.cos(O*Math.PI/180)}" y2="${l+38*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(C*Math.PI/180)}" y2="${l+42*Math.sin(C*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/> <line x1="${d}" y1="${l}" x2="${d+42*Math.cos(D*Math.PI/180)}" y2="${l+42*Math.sin(D*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${s}" cy="${l}" r="3" fill="var(--fg)"/> <circle cx="${d}" cy="${l}" r="3" fill="var(--fg)"/>
</svg>`,J=n.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${n.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${B(n)}</span></div></div>`,L="analog"}function u(){const n=new Date,e=n.getHours()%12||12,o=n.getMinutes(),a=n.getSeconds(),r="clock-widget"+(O!=="default"?" "+O:"");switch(h.className!==r&&(h.className=r),O){case"lcd":H(n);break;case"lcd-blue":H(n);break;case"lcd-amber":H(n);break;case"lcd-retro":H(n);break;case"lcd-taxi":H(n);break;case"flip":R(n);break;case"binary":D(n);break;case"analog":g(n,!1);break;case"roman":g(n,!0);break;default:j(n)}o===0&&a===0&&e!==z&&(z=e,k(e)),o!==0&&(z=-1)}function f(){clearTimeout(M);const n=document.hidden?6e4:1e3,e=n-Date.now()%n+25;M=setTimeout(()=>{u(),f()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),u(),f()}),u(),f();const m=[{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 p='<div class="clock-style-grid">';m.forEach(n=>{p+=`<label class="clock-style-option"> </svg>`,W=a.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${a.getHours()%12||12}:${String(t).padStart(2,"0")} ${W}</span><span class="analog-date-sm">${C(a)}</span></div></div>`,I="analog"}function p(){const a=new Date,e=a.getHours()%12||12,n=a.getMinutes(),t=a.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==i&&(b.className=i),P){case"lcd":B(a);break;case"lcd-blue":B(a);break;case"lcd-amber":B(a);break;case"lcd-retro":B(a);break;case"lcd-taxi":B(a);break;case"flip":A(a);break;case"binary":z(a);break;case"analog":f(a,!1);break;case"roman":f(a,!0);break;default:j(a)}n===0&&t===0&&e!==L&&(L=e,$(e)),n!==0&&(L=-1)}function y(){clearTimeout(x);const a=document.hidden?6e4:1e3,e=a-Date.now()%a+25;x=setTimeout(()=>{p(),y()},e)}document.addEventListener("visibilitychange",()=>{g="",R(),p(),y()}),p(),y();const v=[{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 m='<div class="clock-style-grid">';v.forEach(a=>{m+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${n.id}"> <input type="radio" name="clock-style-radio" value="${a.id}">
<span class="clock-style-card"><span class="clock-style-icon">${n.icon}</span><span class="clock-style-label">${n.label}</span></span> <span class="clock-style-card"><span class="clock-style-icon">${a.icon}</span><span class="clock-style-label">${a.label}</span></span>
</label>`}),p+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal"> </label>`}),m+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="max-width: 420px;"> <div class="weather-modal-content" style="max-width: 420px;">
<h3>Clock Settings</h3> <h3>Clock Settings</h3>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label> <label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${p} ${m}
</div> </div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);"> <div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);"> <label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -1705,7 +1629,8 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
<button id="clock-settings-save">Save</button> <button id="clock-settings-save">Save</button>
</div> </div>
</div> </div>
</div>`);const d=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),i=document.getElementById("clock-chime-volume"),$=document.getElementById("clock-volume-section");function y(){const n=safeGet("clock-style")||"default",e=d.querySelector(`input[value="${n}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",i.value=safeGet("clock-chime-volume")||"50",$.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{$.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),d.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(i.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=n,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const n=d.querySelector('input[name="clock-style-radio"]:checked'),e=n?n.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",i.value),O=e,v="",S(),u(),f(),d.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{d.classList.remove("show")}),wireModal(d),d?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{O=n.value,v="",S(),u()})})})(),(function(){async function h(){try{const A=await(await fetch("/api/v1/health-checks/status")).json();if(!A.success||!A.status)return;for(const[v,L]of Object.entries(A.status)){const b=document.getElementById("uptime-"+v),M=document.getElementById("uptime-bar-"+v);if(!b)continue;const k=L.uptime?.["24h"];if(k!=null){const B=k.toFixed(1);b.textContent=`${B}% uptime`,b.className="uptime-chip",k>=99.9?b.classList.add("excellent"):k>=99?b.classList.add("good"):k>=95?b.classList.add("degraded"):b.classList.add("poor"),M&&(M.style.width=B+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}let P=[];async function w(z){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(b=>b.classList.remove("visible"));const L=v.updates||[];if(P=L,z&&L.length>0){const b=window._lastKnownUpdateCount||0;b>0&&L.length>b&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const b of L){const M=window.APPS||[];for(const k of M)if(k.containerId===b.containerId||k.id===b.containerName||k.name===b.containerName){if(E.has(k.id))break;const B=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);B&&(B.classList.add("visible"),B.title="Update available \u2014 click to open Update Management.",B.style.cursor="pointer",B.onclick=T=>{T.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),S&&(S.style.background="#f97316",S.style.borderColor="#f97316",S.style.boxShadow="0 0 6px #f9731688",S.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),w()},5e3),setInterval(()=>{h(),w(!0)},6e4)}const O=window.refreshAll;O&&(window.refreshAll=async function(){try{await O(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,E=null,P={},w={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["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"]],O=document.getElementById("theme");if(!O)return;var z=document.getElementById("theme-label");function A(a){if(w[a])return w[a];var r=safeGetJSON(window.USER_THEMES_KEY,{});return r[a]&&r[a].name||a}function v(){z&&(z.textContent=A(window.getActiveTheme()))}O.addEventListener("click",function(){var a=window.THEMES.slice(),r=window.getActiveTheme(),t=a.indexOf(r),s=a[(t+1)%a.length];window.applyTheme(s),v()}),v();function L(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated </div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),s=document.getElementById("clock-chime-volume"),h=document.getElementById("clock-volume-section");function u(){const a=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${a}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",s.value=safeGet("clock-chime-volume")||"50",h.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{h.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const a=parseInt(s.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=a,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const a=r.querySelector('input[name="clock-style-radio"]:checked'),e=a?a.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",s.value),P=e,g="",R(),p(),y(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(a=>{a.addEventListener("change",()=>{P=a.value,g="",R(),p()})})})(),(function(){async function b(){try{const L=await(await fetch("/api/v1/health-checks/status")).json();if(!L.success||!L.status)return;for(const[H,g]of Object.entries(L.status)){const I=document.getElementById("uptime-"+H),k=document.getElementById("uptime-bar-"+H);if(!I)continue;const x=g.uptime?.["24h"];if(x!=null){const $=x.toFixed(1);I.textContent=`${$}% uptime`,I.className="uptime-chip",x>=99.9?I.classList.add("excellent"):x>=99?I.classList.add("good"):x>=95?I.classList.add("degraded"):I.classList.add("poor"),k&&(k.style.width=$+"%")}}}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 N(){try{const L=await(await fetch("/api/v1/updates/available")).json();if(!L.success||(document.querySelectorAll(".update-available-badge").forEach(H=>H.classList.remove("visible")),!L.updates?.length))return;for(const H of L.updates){const g=window.APPS||[];for(const I of g)if(I.containerId===H.containerId||I.id===H.containerName||I.name===H.containerName){if(E.has(I.id))break;const k=document.getElementById("update-badge-"+I.id);k&&(k.classList.add("visible"),k.title=`Image digest changed. Click to dismiss if already up to date.
${H.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k.classList.remove("visible"),E.add(I.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function S(){setTimeout(()=>{b(),N()},5e3),setInterval(()=>{b(),N()},6e4)}const T=window.refreshAll;T&&(window.refreshAll=async function(){try{await T(),setTimeout(b,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),S()})(),(function(){var b=null,E=null,N={},S={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},T=[["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"]],P=document.getElementById("theme");if(!P)return;var L=document.getElementById("theme-label");function H(t){if(S[t])return S[t];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[t]&&i[t].name||t}function g(){L&&(L.textContent=H(window.getActiveTheme()))}P.addEventListener("click",function(){var t=window.THEMES.slice(),i=window.getActiveTheme(),o=t.indexOf(i),d=t[(o+1)%t.length];window.applyTheme(d),g()}),g();function I(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},i={};T.forEach(function(d){i[d[2]]||(i[d[2]]=[]),i[d[2]].push(d)});var o="";return Object.keys(t).forEach(function(d){d==="advanced"?(o+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors &#9660;</div>',o+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):o+='<div class="theme-builder-section">',o+='<div class="theme-builder-section-title">'+t[d]+"</div>",(i[d]||[]).forEach(function(l){o+='<div class="theme-builder-row"><span class="theme-builder-label">'+l[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+l[0]+'"'+(d==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+l[0]+'">#000000</span></div>'}),o+="</div>"}),o}function k(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+H(t)+"</option>"}).join("")}var x='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+k()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</ Line truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3> <h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;"> <p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -1742,13 +1667,13 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
<button id="license-activate" class="btn-accent">Activate</button> <button id="license-activate" class="btn-accent">Activate</button>
</div> </div>
</div> </div>
</div>`);const h=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),P=document.getElementById("license-activate"),w=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),O=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),A=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),L=document.getElementById("license-details"),b=document.getElementById("license-feature-list"),M=document.getElementById("license-activate-section");let k=null;function B(){N.style.display="none",O.style.display="none"}function S(f){B(),N.textContent=f,N.style.display="block"}function T(f){B(),O.textContent=f,O.style.display="block"}function j(f){if(k=f,f.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",z.textContent="\u2605",A.textContent="Premium Active";const d=f.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(f.expiresAt).toLocaleDateString()}</strong> (${f.daysRemaining} days remaining)</div>`;L.innerHTML=` </div>`);const b=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),N=document.getElementById("license-activate"),S=document.getElementById("license-deactivate"),T=document.getElementById("license-error"),P=document.getElementById("license-success"),L=document.getElementById("license-badge-icon"),H=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),I=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let $=null;function C(){T.style.display="none",P.style.display="none"}function R(y){C(),T.textContent=y,T.style.display="block"}function M(y){C(),P.textContent=y,P.style.display="block"}function j(y){if($=y,y.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",L.textContent="\u2605",H.textContent="Premium Active";const r=y.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(y.expiresAt).toLocaleDateString()}</strong> (${y.daysRemaining} days remaining)</div>`;I.innerHTML=`
<div>Code: <code style="font-family: monospace;">${f.code||"***"}</code></div> <div>Code: <code style="font-family: monospace;">${y.code||"***"}</code></div>
${d} ${r}
`,M.style.display="none",P.style.display="none",w.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",z.textContent="\u2606",A.textContent=f.expired?"License Expired":"Free Tier",L.innerHTML=f.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",M.style.display="",P.style.display="",w.style.display="none";const m=f.premiumFeatures||{},p=new Set(f.features||[]);b.innerHTML=Object.entries(m).map(([d,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);"> `,x.style.display="none",N.style.display="none",S.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",L.textContent="\u2606",H.textContent=y.expired?"License Expired":"Free Tier",I.innerHTML=y.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",x.style.display="",N.style.display="",S.style.display="none";const v=y.premiumFeatures||{},m=new Set(y.features||[]);k.innerHTML=Object.entries(v).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${p.has(d)?"\u2705":"\u{1F512}"}</span> <span style="font-size: 1.1rem;">${m.has(r)?"\u2705":"\u{1F512}"}</span>
<div> <div>
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div> <div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div> <div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
</div> </div>
</div>`).join("")}async function H(){try{const m=await(await fetch("/api/v1/license/status")).json();m.success&&(j(m.license),D(m.license))}catch(f){console.warn("Failed to load license status:",f.message)}}async function R(){const f=E.value.trim();if(!f){S("Please enter a license code.");return}B(),P.disabled=!0,P.textContent="Activating...";try{const p=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();p.success?(T(p.message),E.value="",j(p.license),showNotification("License activated! Premium features unlocked.","success",5e3),D(p.license)):S(p.error||"Activation failed")}catch(m){S("Network error: "+m.message)}finally{P.disabled=!1,P.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){w.disabled=!0,w.textContent="Deactivating...";try{const m=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();m.success?(T(m.message),await H(),showNotification("License deactivated.","info",3e3),D({active:!1})):S(m.error||"Deactivation failed")}catch(f){S("Network error: "+f.message)}finally{w.disabled=!1,w.textContent="Deactivate"}}}function D(f){const m=document.getElementById("license-status-topbar"),p=document.getElementById("license-topbar-icon"),d=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(m)if(m.className="license-status-topbar "+(f.active?"premium":"free"),f.active)if(p.textContent="\u2605",d.textContent="PREMIUM",f.lifetime)c.textContent="\xB7 LIFETIME";else{const i=f.daysRemaining;c.textContent=i!=null?"\xB7 "+i+"d remaining":""}else p.textContent="\u2606",d.textContent=f.expired?"EXPIRED":"FREE TIER",c.textContent=""}function g(){B(),H(),h.classList.add("show")}E.addEventListener("input",function(){let f=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(f.length>this._prevLength&&(f=f.replace(/-/g,""),f.length>2&&!f.startsWith("DC")&&(f="DC"+f),f.startsWith("DC")&&f.length>2)){const m=["DC"],p=f.substring(2);for(let d=0;d<p.length;d+=5)m.push(p.substring(d,d+5));f=m.join("-")}this._prevLength=f.length,this.value=f}),P.addEventListener("click",R),w.addEventListener("click",x),E.addEventListener("keydown",f=>{f.key==="Enter"&&R()}),wireModal(h,document.getElementById("license-cancel"));const u=document.getElementById("license-status-topbar");u&&u.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=g,window.checkPremiumFeature=async function(f){try{return(await(await fetch(`/api/v1/license/feature/${f}`)).json()).available}catch{return!1}},H().then(f=>{k&&D(k)})})(); </div>`).join("")}async function B(){try{const v=await(await fetch("/api/v1/license/status")).json();v.success&&(j(v.license),z(v.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function A(){const y=E.value.trim();if(!y){R("Please enter a license code.");return}C(),N.disabled=!0,N.textContent="Activating...";try{const m=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();m.success?(M(m.message),E.value="",j(m.license),showNotification("License activated! Premium features unlocked.","success",5e3),z(m.license)):R(m.error||"Activation failed")}catch(v){R("Network error: "+v.message)}finally{N.disabled=!1,N.textContent="Activate"}}async function w(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){S.disabled=!0,S.textContent="Deactivating...";try{const v=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();v.success?(M(v.message),await B(),showNotification("License deactivated.","info",3e3),z({active:!1})):R(v.error||"Deactivation failed")}catch(y){R("Network error: "+y.message)}finally{S.disabled=!1,S.textContent="Deactivate"}}}function z(y){const v=document.getElementById("license-status-topbar"),m=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(v)if(v.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(m.textContent="\u2605",r.textContent="PREMIUM",y.lifetime)c.textContent="\xB7 LIFETIME";else{const s=y.daysRemaining;c.textContent=s!=null?"\xB7 "+s+"d remaining":""}else m.textContent="\u2606",r.textContent=y.expired?"EXPIRED":"FREE TIER",c.textContent=""}function f(){C(),B(),b.classList.add("show")}E.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 v=["DC"],m=y.substring(2);for(let r=0;r<m.length;r+=5)v.push(m.substring(r,r+5));y=v.join("-")}this._prevLength=y.length,this.value=y}),N.addEventListener("click",A),S.addEventListener("click",w),E.addEventListener("keydown",y=>{y.key==="Enter"&&A()}),wireModal(b,document.getElementById("license-cancel"));const p=document.getElementById("license-status-topbar");p&&p.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=f,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},B().then(y=>{$&&z($)})})();
+18 -129
View File
@@ -1,115 +1,4 @@
(function(){function v(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(e=>{window.APPS.find(n=>n.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const a=document.querySelectorAll(".top .card");a.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function E(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(e=>{const n=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[n]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(n&&n.tourCompleted)}catch{}const i=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=a?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const n=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(n),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(n),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const a=await fetch("/api/v1/templates",{cache:"no-store"});if(!a.ok)return;const i=await a.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(a){console.warn("[init] Failed to load template categories:",a)}}function q(){const a=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;a&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",n=i.querySelector('option[value=""]');if(i.innerHTML="",n)i.appendChild(n);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(a).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(i.success&&i.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}f(Line truncated (function(){function p(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}p();function y(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.classList.add("loaded"))})}function n(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let u=!1;async function l(){if(u){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(u=!0,await window.loadServices(),window.buildGrid(),y(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}k(),w()&&m()}function m(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function w(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function b(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",q=>{q.preventDefault();const S=i.classList.toggle("open");h.setAttribute("aria-expanded",S?"true":"false");const f={};document.querySelectorAll(".tools-section").forEach(v=>{f[v.dataset.section]=v.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(f))}))})}b();function k(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{m();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=l,window.loadCustomServices=p,n(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}l()})()})(),(function(){"use strict";const p=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},y=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let n=null,u=null,l=null;function m(){try{w(),document.addEventListener("keydown",b),p("[Keyboard Shortcuts] Initialized"),p("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),p("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function w(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=`
.dc-monitor {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--card-base);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.dc-monitor-card {
padding: 10px 12px;
background: var(--card-bg, rgba(255,255,255,0.04));
border-radius: 8px;
border: 1px solid var(--border);
}
.dc-monitor-label {
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.dc-monitor-value {
font-size: 1.4rem;
font-weight: 600;
color: var(--fg);
}
.dc-monitor-sub {
font-size: 0.7rem;
color: var(--muted);
margin-top: 4px;
}
.dc-monitor-bar {
margin-top: 6px;
width: 100%;
height: 4px;
background: color-mix(in srgb, var(--muted) 20%, transparent);
border-radius: 2px;
overflow: hidden;
}
.dc-monitor-bar-fill {
height: 100%;
width: 0%;
background: var(--ok-fg, #27ae60);
transition: width 0.3s ease, background 0.3s ease;
}
.dc-monitor-bar-fill.warn { background: #f39c12; }
.dc-monitor-bar-fill.bad { background: #e74c3c; }
.dc-monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.dc-monitor-title {
font-size: 0.85rem;
font-weight: 500;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.dc-monitor-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 500;
}
.dc-monitor-pill.ok { background: color-mix(in srgb, #27ae60 20%, transparent); color: #27ae60; }
.dc-monitor-pill.warn { background: color-mix(in srgb, #f39c12 20%, transparent); color: #f39c12; }
.dc-monitor-pill.bad { background: color-mix(in srgb, #e74c3c 20%, transparent); color: #e74c3c; }
.dc-monitor-refresh {
font-size: 0.7rem;
color: var(--muted);
opacity: 0.7;
}
`,document.head.appendChild(v);const k=document.getElementById("service-filter-bar");if(!k)return;const u=document.createElement("div");u.className="dc-monitor",u.id="dc-monitor-panel",u.innerHTML=`
<div class="dc-monitor-header" style="grid-column: 1 / -1;">
<div class="dc-monitor-title">\u{1F4CA} System Overview</div>
<span class="dc-monitor-refresh" id="dc-monitor-refresh-stamp">\u2014</span>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Services</div>
<div class="dc-monitor-value" id="dc-monitor-services">\u2014</div>
<div class="dc-monitor-sub" id="dc-monitor-services-sub">loading\u2026</div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Containers Up</div>
<div class="dc-monitor-value" id="dc-monitor-containers">\u2014</div>
<div class="dc-monitor-sub" id="dc-monitor-containers-sub">loading\u2026</div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Avg CPU</div>
<div class="dc-monitor-value" id="dc-monitor-cpu">\u2014</div>
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-cpu-bar"></div></div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Avg Memory</div>
<div class="dc-monitor-value" id="dc-monitor-mem">\u2014</div>
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-mem-bar"></div></div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Health</div>
<div class="dc-monitor-value" id="dc-monitor-health">\u2014</div>
<div class="dc-monitor-sub" id="dc-monitor-health-sub">\u2014</div>
</div>
`,k.parentNode.insertBefore(u,k);function h(e,n){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(n)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<n.length-1;)e/=1024,c++;return e.toFixed(1)+" "+n[c]}function C(){const e=(window.APPS||[]).length;let n=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&n++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${n} online \xB7 ${e-n} offline`)}function E(e){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!e||e.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;n.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const n=await e.json();return n&&n.stats?n.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){n&&(n.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(n&&(n.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let a=!1;async function i(){if(!a){a=!0;try{C();const[e,n]=await Promise.all([S(),y()]);q(e),E(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
<div class="quick-search-content"> <div class="quick-search-content">
<div class="quick-search-input-wrapper"> <div class="quick-search-input-wrapper">
<span class="quick-search-icon">\u{1F50D}</span> <span class="quick-search-icon">\u{1F50D}</span>
@@ -271,7 +160,7 @@
font-family: monospace; font-family: monospace;
margin-right: 4px; margin-right: 4px;
} }
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),a()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function a(){const t=` `,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",h),u.addEventListener("keydown",v),n.addEventListener("click",e=>{e.target===n&&a()})}function b(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),k();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){a();return}c()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function k(){try{n.classList.add("show"),u.value="",u.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function a(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function c(){for(const t of y){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function i(){const t=`
<div class="quick-search-category">Quick Actions</div> <div class="quick-search-category">Quick Actions</div>
<div class="quick-search-item" data-action="refresh"> <div class="quick-search-item" data-action="refresh">
<span class="quick-search-item-icon">\u{1F504}</span> <span class="quick-search-item-icon">\u{1F504}</span>
@@ -303,29 +192,29 @@
</div> </div>
<div class="quick-search-category">Services</div> <div class="quick-search-category">Services</div>
${i()} ${r()}
`;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=` `;l.innerHTML=t,f()}function r(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",g=s.dataset.app||"";d&&d!=="--"&&(e+=`
<div class="quick-search-item" data-action="open-service" data-service="${w}"> <div class="quick-search-item" data-action="open-service" data-service="${g}">
<span class="quick-search-item-icon">${d==="on"?"\u{1F7E2}":"\u{1F534}"}</span> <span class="quick-search-item-icon">${o==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
<div class="quick-search-item-content"> <div class="quick-search-item-content">
<div class="quick-search-item-title">${p}</div> <div class="quick-search-item-title">${d}</div>
<div class="quick-search-item-description">Click to open service</div> <div class="quick-search-item-description">Click to open service</div>
</div> </div>
<span class="quick-search-item-badge">${d.toUpperCase()}</span> <span class="quick-search-item-badge">${o.toUpperCase()}</span>
</div> </div>
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){a();return}const r=n(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function n(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=` `)}),e||'<div class="quick-search-empty">No services found</div>'}function h(t){try{const e=t.target.value.toLowerCase().trim();if(!e){i();return}const s=q(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function q(t){const e={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const g=o.querySelector(".name")?.textContent||"",E=o.dataset.app||"",C=o.dataset.status||"unknown";(g.toLowerCase().includes(t)||E.toLowerCase().includes(t))&&e.services.push({id:E,title:g,status:C,icon:C==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=`
<div class="quick-search-item" data-action="${r.id}"> <div class="quick-search-item" data-action="${s.id}">
<span class="quick-search-item-icon">${r.icon}</span> <span class="quick-search-item-icon">${s.icon}</span>
<div class="quick-search-item-content"> <div class="quick-search-item-content">
<div class="quick-search-item-title">${r.title}</div> <div class="quick-search-item-title">${s.title}</div>
</div> </div>
</div> </div>
`})),t.services.length>0&&(o+='<div class="quick-search-category">Services</div>',t.services.forEach(r=>{o+=` `})),t.services.length>0&&(e+='<div class="quick-search-category">Services</div>',t.services.forEach(s=>{e+=`
<div class="quick-search-item" data-action="open-service" data-service="${r.id}"> <div class="quick-search-item" data-action="open-service" data-service="${s.id}">
<span class="quick-search-item-icon">${r.icon}</span> <span class="quick-search-item-icon">${s.icon}</span>
<div class="quick-search-item-content"> <div class="quick-search-item-content">
<div class="quick-search-item-title">${r.title}</div> <div class="quick-search-item-title">${s.title}</div>
</div> </div>
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span> <span class="quick-search-item-badge">${s.status.toUpperCase()}</span>
</div> </div>
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})(); `})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,f()}function f(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>x(e)),s===0&&e.classList.add("selected")})}function v(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&x(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function x(t){try{const e=t.dataset.action,s=t.dataset.service;switch(a(),e){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(s){const d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:p("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:k,closeQuickSearch:a}})();
-3
View File
@@ -256,9 +256,6 @@
<option value="on">🟢 Online</option> <option value="on">🟢 Online</option>
<option value="off">🔴 Offline</option> <option value="off">🔴 Offline</option>
</select> </select>
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
<option value="all">All Categories</option>
</select>
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button> <button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span> <span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
</div> </div>
-15
View File
@@ -95,8 +95,6 @@
const card = el('div', 'card'); const card = el('div', 'card');
card.setAttribute('data-app', s.id); card.setAttribute('data-app', s.id);
card.setAttribute('data-status', 'off'); // Initial status card.setAttribute('data-status', 'off'); // Initial status
if (s.containerId) card.setAttribute('data-container-id', s.containerId);
if (s.category) card.setAttribute('data-category', s.category);
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId); if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot); const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
@@ -158,16 +156,6 @@
nameSpan.appendChild(tsBadge); nameSpan.appendChild(tsBadge);
} }
// Add Category badge if service has one (colored pill with icon)
if (s.category) {
const cats = (typeof DC !== 'undefined' && DC.CATEGORIES) || window.DC_CATEGORIES || {};
const catInfo = cats[s.category] || {};
const catBadge = el('span', 'cat-badge', `${catInfo.icon || ''} ${s.category}`.trim());
catBadge.title = `Category: ${s.category}`;
catBadge.style.cssText = `margin-left: 6px; font-size: 0.65rem; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 25%, transparent); color: ${catInfo.color || '#7f8c8d'}; border: 1px solid color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 50%, transparent); white-space: nowrap; font-weight: 500;`;
nameSpan.appendChild(catBadge);
}
row.appendChild(el('span', 'spacer')); row.appendChild(el('span', 'spacer'));
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill); const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
@@ -294,9 +282,6 @@
// Group recipe cards visually after grid is built // Group recipe cards visually after grid is built
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards()); if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
// Refresh the service filter so the category dropdown reflects new services
if (window.refreshServiceFilter) window.refreshServiceFilter();
} }
function setBadge(id, up, responseTime = null) { function setBadge(id, up, responseTime = null) {
-51
View File
@@ -59,13 +59,11 @@
} }
_dashboardInitialized = true; _dashboardInitialized = true;
await window.loadServices(); await window.loadServices();
await loadTemplateCategories();
window.buildGrid(); window.buildGrid();
animateTopCards(); animateTopCards();
window.refreshAll(); window.refreshAll();
setInterval(window.refreshAll, DC.POLL.DASHBOARD); setInterval(window.refreshAll, DC.POLL.DASHBOARD);
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons(); if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct) // Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
if (typeof window._updateAuthCard === 'function') { if (typeof window._updateAuthCard === 'function') {
try { try {
@@ -202,55 +200,6 @@
window.loadCustomServices = loadCustomServices; window.loadCustomServices = loadCustomServices;
registerServiceWorker(); registerServiceWorker();
// ===== TEMPLATE CATEGORIES =====
// Cached template categories from /api/v1/templates for use across the UI
// (service create/edit, filter dropdown, category badges, etc.)
async function loadTemplateCategories() {
try {
const r = await fetch('/api/v1/templates', { cache: 'no-store' });
if (!r.ok) return;
const data = await r.json();
if (data && data.categories) {
window.DC_CATEGORIES = data.categories;
// Also expose via globals.js constant for convenience
if (typeof DC !== 'undefined') DC.CATEGORIES = data.categories;
// Populate any category <select> that's already in the DOM
populateCategorySelects();
}
} catch (e) {
console.warn('[init] Failed to load template categories:', e);
}
}
function populateCategorySelects() {
const cats = window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES);
if (!cats) return;
document.querySelectorAll('select[data-role="service-category"]').forEach(select => {
const current = select.dataset.current || '';
// Clear options but keep the first (placeholder)
const placeholder = select.querySelector('option[value=""]');
select.innerHTML = '';
if (placeholder) select.appendChild(placeholder);
else {
const ph = document.createElement('option');
ph.value = '';
ph.textContent = '— Select category —';
select.appendChild(ph);
}
Object.entries(cats).forEach(([name, info]) => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = `${info.icon || ''} ${name}`.trim();
if (name === current) opt.selected = true;
select.appendChild(opt);
});
});
}
// Allow other modules to re-run population after they (re)inject selects
window.populateCategorySelects = populateCategorySelects;
window.loadTemplateCategories = loadTemplateCategories;
// TOTP-gated initialization // TOTP-gated initialization
(async () => { (async () => {
try { try {
-12
View File
@@ -262,7 +262,6 @@
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost'; const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
const preserveHost = document.getElementById('external-preserve-host').checked; const preserveHost = document.getElementById('external-preserve-host').checked;
const followRedirects = document.getElementById('external-follow-redirects').checked; const followRedirects = document.getElementById('external-follow-redirects').checked;
const category = document.getElementById('external-service-category')?.value || '';
if (!name || !externalUrl) { if (!name || !externalUrl) {
showNotification('Please fill in Name and External URL', 'warning'); showNotification('Please fill in Name and External URL', 'warning');
@@ -342,8 +341,6 @@
isExternal: true, isExternal: true,
isCustom: true isCustom: true
}; };
// Only attach category if user actually picked one
if (category) newService.category = category;
window.APPS.push(newService); window.APPS.push(newService);
results.dashboard = true; results.dashboard = true;
@@ -460,13 +457,6 @@
const healthCheck = document.getElementById('health-check-input')?.value || ''; const healthCheck = document.getElementById('health-check-input')?.value || '';
const timeout = document.getElementById('timeout-input')?.value || 30; const timeout = document.getElementById('timeout-input')?.value || 30;
// Category is optional — pulled from either local or external select by the
// openAddServiceModal reset. If user doesn't choose one, it stays undefined
// and we don't send it (so the backend keeps the existing behavior).
const categoryEl = document.getElementById('service-category-input')
|| document.getElementById('external-service-category');
const category = categoryEl?.value || '';
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin'); const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
if (!name || !port || !ip) { if (!name || !port || !ip) {
@@ -535,8 +525,6 @@
logo: logo || `/assets/${subdomain}.png`, logo: logo || `/assets/${subdomain}.png`,
tailscaleOnly: tailscaleOnly || false tailscaleOnly: tailscaleOnly || false
}; };
// Only include category if user actually picked one
if (category) serviceConfig.category = category;
await window.addServiceToConfig(serviceConfig); await window.addServiceToConfig(serviceConfig);
results.dashboard = true; results.dashboard = true;
+2 -16
View File
@@ -19,16 +19,6 @@
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false; document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
document.getElementById('edit-logo-url').value = service.logo || ''; document.getElementById('edit-logo-url').value = service.logo || '';
// Populate the category select for this service, then set the current value.
// populateCategorySelects() uses data-current so we set it first, then call.
const categorySelect = document.getElementById('edit-service-category');
if (categorySelect) {
categorySelect.dataset.current = service.category || '';
if (typeof window.populateCategorySelects === 'function') {
window.populateCategorySelects();
}
}
modal.classList.add('show'); modal.classList.add('show');
} }
@@ -46,7 +36,6 @@
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost'; const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked; const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
const newLogo = document.getElementById('edit-logo-url').value.trim(); const newLogo = document.getElementById('edit-logo-url').value.trim();
const newCategory = document.getElementById('edit-service-category')?.value || '';
if (!newSubdomain) { if (!newSubdomain) {
showNotification('Subdomain is required', 'warning'); showNotification('Subdomain is required', 'warning');
@@ -62,7 +51,6 @@
if (newIp !== currentEditService.ip) changes.push('ip'); if (newIp !== currentEditService.ip) changes.push('ip');
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale'); if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo'); if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
if (newCategory !== (currentEditService.category || '')) changes.push('category');
if (changes.length === 0) { if (changes.length === 0) {
closeServiceEditModal(); closeServiceEditModal();
@@ -84,8 +72,7 @@
port: newPort || currentEditService.port, port: newPort || currentEditService.port,
ip: newIp, ip: newIp,
tailscaleOnly, tailscaleOnly,
logo: newLogo || undefined, logo: newLogo || undefined
category: newCategory
}) })
}); });
@@ -104,8 +91,7 @@
port: newPort || window.APPS[appIndex].port, port: newPort || window.APPS[appIndex].port,
ip: newIp, ip: newIp,
tailscaleOnly, tailscaleOnly,
logo: newLogo || window.APPS[appIndex].logo, logo: newLogo || window.APPS[appIndex].logo
category: newCategory || undefined
}; };
} }
-3
View File
@@ -187,9 +187,6 @@
name: serviceConfig.name, name: serviceConfig.name,
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png` logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
}; };
// Forward optional metadata fields if provided
if (serviceConfig.category) newService.category = serviceConfig.category;
if (serviceConfig.containerId) newService.containerId = serviceConfig.containerId;
try { try {
const response = await secureFetch('/api/v1/services', { const response = await secureFetch('/api/v1/services', {
-27
View File
@@ -82,16 +82,6 @@
Enter a URL or upload an image file (PNG, JPG, SVG) Enter a URL or upload an image file (PNG, JPG, SVG)
</div> </div>
</div> </div>
<!-- Category -->
<div>
<label for="edit-service-category" class="form-label-accent-sm">
Category
</label>
<select id="edit-service-category" data-role="service-category" class="form-input-md">
<option value="">— No category —</option>
</select>
</div>
</div> </div>
<div class="weather-modal-buttons" style="margin-top: 24px;"> <div class="weather-modal-buttons" style="margin-top: 24px;">
@@ -249,15 +239,6 @@
Reload Caddy after adding Reload Caddy after adding
</label> </label>
<!-- Category -->
<div>
<label for="service-category-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
<select id="service-category-input" data-role="service-category" style="width: 100%;">
<option value="">— No category —</option>
</select>
<div style="font-size: 0.7rem; color: var(--muted); margin-top: 3px;">Group services on the dashboard by purpose (Media, Productivity, etc.)</div>
</div>
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" /> <hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
<div class="grid-2col"> <div class="grid-2col">
@@ -345,14 +326,6 @@
Follow Redirects Follow Redirects
</label> </label>
<!-- Category (external) -->
<div>
<label for="external-service-category" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
<select id="external-service-category" data-role="service-category" style="width: 100%;">
<option value="">— No category —</option>
</select>
</div>
</div> </div>
</details> </details>
</div> </div>
+2 -45
View File
@@ -2,50 +2,11 @@
(function() { (function() {
const searchInput = document.getElementById('service-filter-search'); const searchInput = document.getElementById('service-filter-search');
const statusSelect = document.getElementById('service-filter-status'); const statusSelect = document.getElementById('service-filter-status');
const categorySelect = document.getElementById('service-filter-category');
const countSpan = document.getElementById('service-filter-count'); const countSpan = document.getElementById('service-filter-count');
// Build a single category list from both the API categories and any
// categories present on the actual rendered cards (covers custom services
// whose category isn't in TEMPLATE_CATEGORIES).
function getCategoryList() {
const seen = new Set();
const fromCards = new Set();
document.querySelectorAll('#cards .card[data-category]').forEach(c => {
const cat = c.dataset.category.trim();
if (cat) fromCards.add(cat);
});
const apiCats = (window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES)) || {};
const all = Object.keys(apiCats).concat([...fromCards].filter(c => !apiCats[c]));
all.forEach(c => seen.add(c));
return { list: [...seen], apiCats };
}
function refreshCategoryDropdown() {
if (!categorySelect) return;
const { list, apiCats } = getCategoryList();
const current = categorySelect.value;
categorySelect.innerHTML = '<option value="all">All Categories</option>';
list.sort().forEach(name => {
const info = apiCats[name];
const opt = document.createElement('option');
opt.value = name;
opt.textContent = info ? `${info.icon || ''} ${name}`.trim() : name;
categorySelect.appendChild(opt);
});
// Restore selection if it still exists
if (current && [...categorySelect.options].some(o => o.value === current)) {
categorySelect.value = current;
} else {
categorySelect.value = 'all';
}
}
function updateFilter() { function updateFilter() {
refreshCategoryDropdown();
const query = searchInput.value.toLowerCase().trim(); const query = searchInput.value.toLowerCase().trim();
const statusFilter = statusSelect.value; // 'all', 'on', or 'off' const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
const categoryFilter = categorySelect ? categorySelect.value : 'all';
const cards = document.querySelectorAll('#cards .card'); const cards = document.querySelectorAll('#cards .card');
let visibleCount = 0; let visibleCount = 0;
@@ -54,13 +15,11 @@
const name = card.querySelector('.name')?.textContent?.toLowerCase() || ''; const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
const app = card.dataset.app?.toLowerCase() || ''; const app = card.dataset.app?.toLowerCase() || '';
const status = card.dataset.status || 'off'; // 'on' or 'off' const status = card.dataset.status || 'off'; // 'on' or 'off'
const category = card.dataset.category || '';
const matchesSearch = !query || name.includes(query) || app.includes(query); const matchesSearch = !query || name.includes(query) || app.includes(query);
const matchesStatus = statusFilter === 'all' || status === statusFilter; const matchesStatus = statusFilter === 'all' || status === statusFilter;
const matchesCategory = categoryFilter === 'all' || category === categoryFilter;
if (matchesSearch && matchesStatus && matchesCategory) { if (matchesSearch && matchesStatus) {
card.style.display = ''; card.style.display = '';
visibleCount++; visibleCount++;
} else { } else {
@@ -85,7 +44,6 @@
searchInput?.addEventListener('input', debounce(updateFilter, 200)); searchInput?.addEventListener('input', debounce(updateFilter, 200));
statusSelect?.addEventListener('change', updateFilter); statusSelect?.addEventListener('change', updateFilter);
categorySelect?.addEventListener('change', updateFilter);
// Initial count on page load // Initial count on page load
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -94,7 +52,6 @@
setTimeout(updateFilter, 500); setTimeout(updateFilter, 500);
} }
// Expose for external triggers (called after buildGrid to repopulate categories) // Expose for external triggers
window.refreshServiceFilter = updateFilter; window.refreshServiceFilter = updateFilter;
window.refreshCategoryDropdown = refreshCategoryDropdown;
})(); })();
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-43a872cc40'; const CACHE = 'dashcaddy-shell-8ef9c82616';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',