Compare commits

...
7 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
Hermes ea9bdf9598 Backup data/ dir before update, restore on rollback
- Add backup_data_dir() and restore_data_dir() using rsync
- Data backed up to backups/{version}/data-backup/ alongside code
- restore_data_dir() called in all three rollback paths (build fail, restart fail, health check fail)
- Add restart_container() that does rm + run to apply new env vars
- Handle action=rollback explicitly (no new version deployment)
- Uses standalone docker build instead of compose for reliability
- Add start.sh at /opt/dashcaddy/start.sh for reboot survival
2026-05-28 02:34:27 -07:00
Hermes c52016d727 fix: backup and restore data/ dir on update and rollback
The data/ directory (services.json, config.json, credentials,
TOTP config, notifications) was never included in the update
backup. Every update wiped user data — services, licenses,
credentials — requiring manual restore.

Now the host-side updater:
- Backs up data/ alongside code files before any update
- Restores data/ on rollback (build failure, restart failure,
  or health-check failure)
2026-05-28 02:08:33 -07:00
Hermes 588188edb5 update UX: badge→modal flow, orange update button, Update All, toast notifications, workflow triggers 2026-05-27 23:57:32 -07:00
Hermes 11823a1466 feat: premium tier features — auto-backup scheduling, resource alerting, bundled workflows, one-click revert, notification manager 2026-05-27 23:39:46 -07:00
30 changed files with 4607 additions and 828 deletions
+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."
}
]
} }
}; };
+82 -305
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');
@@ -75,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) {
@@ -84,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
@@ -176,6 +184,11 @@ class BackupManager extends EventEmitter {
await this.cleanupOldBackups(name, backup.retention); await this.cleanupOldBackups(name, backup.retention);
} }
// Enforce storage limit (delete oldest until within maxStorageBytes)
if (backup.maxStorageBytes) {
await this.enforceStorageLimit(name, backup.maxStorageBytes);
}
this.emit('backup-complete', historyEntry); this.emit('backup-complete', historyEntry);
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`); console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
@@ -543,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 });
@@ -586,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,
@@ -596,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
*/ */
@@ -881,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) {
@@ -995,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
*/ */
@@ -1009,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);
+575
View File
@@ -0,0 +1,575 @@
/**
* Bundled Workflows - Pre-configured automation templates
*
* Workflows attach to events (container-down, pre-update, resource-alert, scheduled)
* and execute a sequence of actions when triggered.
*/
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const WORKFLOWS_FILE = process.env.WORKFLOWS_FILE || path.join(__dirname, 'workflows-config.json');
const WORKFLOW_HISTORY_FILE = process.env.WORKFLOW_HISTORY_FILE || path.join(__dirname, 'workflow-history.json');
/**
* Bundled workflow templates
*/
const BUNDLED_WORKFLOWS = {
'auto-restart-on-crash': {
id: 'auto-restart-on-crash',
name: 'Auto-Restart on Crash',
description: 'Automatically restart a container when it goes down',
trigger: 'container-down',
actions: [
{ type: 'docker-restart', containerId: '{{containerId}}' },
{ type: 'notify', message: 'Container {{containerId}} restarted automatically' }
]
},
'backup-before-update': {
id: 'backup-before-update',
name: 'Backup Before Update',
description: 'Create a backup before any app update',
trigger: 'pre-update',
actions: [
{ type: 'backup-create', appId: '{{appId}}', label: 'pre-update' },
{ type: 'notify', message: 'Backup created before updating {{appId}}' }
]
},
'health-check-on-interval': {
id: 'health-check-on-interval',
name: 'Periodic Health Check',
description: 'Run health checks every 15 minutes and alert if degraded',
trigger: 'scheduled',
interval: 15 * 60 * 1000, // 15 minutes
actions: [
{ type: 'health-check', target: '{{serviceId}}' },
{ type: 'notify-on-failure', message: 'Health check failed for {{serviceId}}' }
]
},
'disk-space-alert': {
id: 'disk-space-alert',
name: 'Disk Space Alert',
description: 'Alert when disk usage exceeds 80%',
trigger: 'resource-alert',
condition: 'diskPercent > 80',
actions: [
{ type: 'notify', message: '⚠️ Disk usage at {{diskPercent}}% on {{host}}' }
]
},
'weekly-container-report': {
id: 'weekly-container-report',
name: 'Weekly Container Report',
description: 'Send a weekly summary of container status and resource usage',
trigger: 'scheduled',
interval: 7 * 24 * 60 * 60 * 1000, // weekly
actions: [
{ type: 'collect-metrics', period: '7d' },
{ type: 'notify', message: '{{report}}' }
]
}
};
/**
* WorkflowEngine - Executes bundled workflows
*/
class WorkflowEngine extends EventEmitter {
constructor(ctx) {
super();
this.ctx = ctx;
this.enabled = new Map();
this.history = [];
this.scheduledJobs = new Map();
this.loadConfig();
this.loadHistory();
this.startScheduledWorkflows();
}
/**
* Load enabled/disabled state for workflows
*/
loadConfig() {
try {
if (fs.existsSync(WORKFLOWS_FILE)) {
const data = JSON.parse(fs.readFileSync(WORKFLOWS_FILE, 'utf8'));
this.enabled = new Map(Object.entries(data.enabled || {}));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading config:', error.message);
}
// Default all workflows to enabled if not explicitly set
for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
if (!this.enabled.has(id)) {
this.enabled.set(id, true);
}
}
}
/**
* Save enabled/disabled state
*/
saveConfig() {
try {
const data = {
enabled: Object.fromEntries(this.enabled)
};
fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving config:', error.message);
}
}
/**
* Load execution history
*/
loadHistory() {
try {
if (fs.existsSync(WORKFLOW_HISTORY_FILE)) {
this.history = JSON.parse(fs.readFileSync(WORKFLOW_HISTORY_FILE, 'utf8'));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading history:', error.message);
this.history = [];
}
}
/**
* Save execution history
*/
saveHistory() {
try {
fs.writeFileSync(WORKFLOW_HISTORY_FILE, JSON.stringify(this.history, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving history:', error.message);
}
}
/**
* Start scheduled workflows
*/
startScheduledWorkflows() {
for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
if (workflow.trigger === 'scheduled' && workflow.interval) {
this.startScheduledWorkflow(id, workflow);
}
}
}
/**
* Start a scheduled workflow
*/
startScheduledWorkflow(workflowId, workflow) {
if (!this.enabled.get(workflowId)) return;
// Clear existing job if any
this.stopScheduledWorkflow(workflowId);
const job = setInterval(() => {
this.executeWorkflow(workflowId, { trigger: 'scheduled', timestamp: new Date().toISOString() })
.catch(err => console.error(`[WorkflowEngine] Scheduled workflow ${workflowId} failed:`, err.message));
}, workflow.interval);
this.scheduledJobs.set(workflowId, job);
console.log(`[WorkflowEngine] Scheduled workflow '${workflowId}' every ${workflow.interval}ms`);
}
/**
* Stop a scheduled workflow
*/
stopScheduledWorkflow(workflowId) {
if (this.scheduledJobs.has(workflowId)) {
clearInterval(this.scheduledJobs.get(workflowId));
this.scheduledJobs.delete(workflowId);
}
}
/**
* Execute a workflow by ID
*/
async executeWorkflow(workflowId, triggerData = {}) {
const workflow = BUNDLED_WORKFLOWS[workflowId];
if (!workflow) {
throw new Error(`Unknown workflow: ${workflowId}`);
}
if (!this.enabled.get(workflowId)) {
console.log(`[WorkflowEngine] Workflow ${workflowId} is disabled, skipping`);
return { skipped: true, reason: 'disabled' };
}
const executionId = `${workflowId}-${Date.now()}`;
const startTime = Date.now();
console.log(`[WorkflowEngine] Executing workflow: ${workflowId}`);
this.emit('workflow-start', { workflowId, executionId, triggerData });
const results = [];
for (const action of workflow.actions) {
try {
const result = await this.executeAction(action, triggerData);
results.push({ action: action.type, success: true, result });
} catch (error) {
console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message);
results.push({ action: action.type, success: false, error: error.message });
// Continue with other actions but log failure
}
}
const duration = Date.now() - startTime;
const allSucceeded = results.every(r => r.success);
const historyEntry = {
executionId,
workflowId,
workflowName: workflow.name,
trigger: triggerData.trigger || 'manual',
timestamp: new Date().toISOString(),
duration,
success: allSucceeded,
results
};
this.history.push(historyEntry);
// Keep history to last 500 entries
if (this.history.length > 500) {
this.history = this.history.slice(-500);
}
this.saveHistory();
this.emit('workflow-complete', historyEntry);
console.log(`[WorkflowEngine] Workflow ${workflowId} completed in ${duration}ms, success: ${allSucceeded}`);
return historyEntry;
}
/**
* Execute a single action
*/
async executeAction(action, context) {
switch (action.type) {
case 'docker-restart':
return this.restartContainer(this.interpolate(action.containerId, context));
case 'backup-create':
return this.createBackup(
this.interpolate(action.appId, context),
action.label
);
case 'notify':
return this.notify(
this.interpolate(action.message, context),
action.channel
);
case 'notify-on-failure':
// Only send if previous action failed
return this.notify(
this.interpolate(action.message, context),
action.channel
);
case 'health-check':
return this.healthCheckService(this.interpolate(action.target, context));
case 'collect-metrics':
return this.collectMetrics(context.containerId, action.period);
default:
console.warn(`[WorkflowEngine] Unknown action type: ${action.type}`);
return { skipped: true, reason: `Unknown action type: ${action.type}` };
}
}
/**
* Interpolate template variables in a string
*/
interpolate(str, context) {
if (!str || typeof str !== 'string') return str;
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return context[key] !== undefined ? context[key] : match;
});
}
/**
* Health check action
*/
async healthCheckService(serviceId) {
if (!serviceId || serviceId === '{{serviceId}}') {
// Run health check on all services
const results = [];
const servicesStateManager = this.ctx.servicesStateManager;
if (servicesStateManager) {
const services = servicesStateManager.getState() || [];
for (const service of services) {
if (service.containerId) {
try {
const healthy = await this.checkContainerHealth(service.containerId);
results.push({ service: service.id, healthy });
} catch (e) {
results.push({ service: service.id, healthy: false, error: e.message });
}
}
}
}
return { checked: results.length, healthy: results.filter(r => r.healthy).length, results };
}
// Single service check
const healthy = await this.checkContainerHealth(serviceId);
return { serviceId, healthy };
}
/**
* Check if a container is healthy
*/
async checkContainerHealth(containerId) {
try {
const docker = this.ctx.docker?.client;
if (!docker) return false;
const container = docker.getContainer(containerId);
const info = await container.inspect();
return info.State && info.State.Running && info.State.Health !== 'unhealthy';
} catch (error) {
return false;
}
}
/**
* Docker restart action
*/
async restartContainer(containerId) {
const docker = this.ctx.docker?.client;
if (!docker) {
throw new Error('Docker client not available');
}
if (!containerId || containerId === '{{containerId}}') {
throw new Error('Container ID not provided');
}
console.log(`[WorkflowEngine] Restarting container: ${containerId}`);
const container = docker.getContainer(containerId);
await container.restart();
return { restarted: containerId };
}
/**
* Backup create action
*/
async createBackup(appId, label = 'workflow') {
const backupManager = this.ctx.backupManager;
if (!backupManager) {
throw new Error('Backup manager not available');
}
if (!appId || appId === '{{appId}}') {
throw new Error('App ID not provided');
}
console.log(`[WorkflowEngine] Creating backup for: ${appId}`);
// Use backup manager's executeBackup if available
const backupName = `${appId}-${label}`;
const backupConfig = backupManager.config?.backups?.[appId];
if (backupConfig) {
const result = await backupManager.executeBackup(backupName, backupConfig);
return { backupId: result.backupId, appId, label };
}
// Fallback: trigger manual backup via backup manager
if (backupManager.executeBackup) {
const result = await backupManager.executeBackup(appId, {
include: ['config', 'data'],
schedule: 'manual'
});
return { backupId: result.backupId, appId, label };
}
throw new Error('Backup execution not available');
}
/**
* Notify action
*/
async notify(message, channel) {
const notification = this.ctx.notification;
if (!notification) {
console.warn('[WorkflowEngine] Notification manager not available');
return { notified: false, reason: 'no notification manager' };
}
console.log(`[WorkflowEngine] Sending notification: ${message}`);
notification.send('workflow', 'Workflow Notification', message, 'info');
return { notified: true, message };
}
/**
* Collect metrics action
*/
async collectMetrics(containerId, period = '7d') {
const resourceMonitor = this.ctx.resourceMonitor;
if (!resourceMonitor) {
throw new Error('Resource monitor not available');
}
// Get aggregated stats
const stats = resourceMonitor.getAllStats();
// Build report
let report = `# Weekly Container Report\n\n`;
report += `Generated: ${new Date().toLocaleString()}\n\n`;
for (const [id, info] of Object.entries(stats)) {
const agg = info.aggregated;
report += `## ${info.name || id}\n`;
report += `- Status: ${info.current?.status || 'unknown'}\n`;
if (agg) {
report += `- CPU: avg ${agg.cpu?.avg?.toFixed(1)}%, max ${agg.cpu?.max?.toFixed(1)}%\n`;
report += `- Memory: avg ${agg.memory?.avg?.toFixed(1)}%, max ${agg.memory?.max?.toFixed(1)}%\n`;
}
report += '\n';
}
return { report, containerCount: Object.keys(stats).length };
}
/**
* List all available workflows
*/
listWorkflows() {
return Object.entries(BUNDLED_WORKFLOWS).map(([id, workflow]) => ({
...workflow,
enabled: this.enabled.get(id) ?? true
}));
}
/**
* Enable or disable a workflow
*/
setWorkflowEnabled(workflowId, enabled) {
if (!BUNDLED_WORKFLOWS[workflowId]) {
throw new Error(`Unknown workflow: ${workflowId}`);
}
this.enabled.set(workflowId, enabled);
this.saveConfig();
// Handle scheduled workflows
const workflow = BUNDLED_WORKFLOWS[workflowId];
if (workflow.trigger === 'scheduled' && workflow.interval) {
if (enabled) {
this.startScheduledWorkflow(workflowId, workflow);
} else {
this.stopScheduledWorkflow(workflowId);
}
}
console.log(`[WorkflowEngine] Workflow ${workflowId} ${enabled ? 'enabled' : 'disabled'}`);
return { workflowId, enabled };
}
/**
* Get execution history for a workflow
*/
getHistory(workflowId = null, limit = 50) {
let history = this.history;
if (workflowId) {
history = history.filter(h => h.workflowId === workflowId);
}
return history.slice(-limit).reverse();
}
/**
* Trigger workflows for a specific event
*/
async triggerForEvent(eventType, eventData) {
const matchingWorkflows = Object.entries(BUNDLED_WORKFLOWS)
.filter(([id, workflow]) => {
if (workflow.trigger !== eventType) return false;
if (!this.enabled.get(id)) return false;
// Check condition if specified
if (workflow.condition && eventData) {
try {
// Simple condition evaluation
const conditionMet = this.evaluateCondition(workflow.condition, eventData);
return conditionMet;
} catch (e) {
console.warn(`[WorkflowEngine] Condition evaluation failed for ${id}:`, e.message);
return false;
}
}
return true;
});
const results = [];
for (const [workflowId, workflow] of matchingWorkflows) {
try {
const result = await this.executeWorkflow(workflowId, {
trigger: eventType,
timestamp: new Date().toISOString(),
...eventData
});
results.push({ workflowId, success: true, result });
} catch (error) {
results.push({ workflowId, success: false, error: error.message });
}
}
return results;
}
/**
* Evaluate a simple condition string
*/
evaluateCondition(condition, data) {
// Simple condition like "diskPercent > 80"
// Supports: >, <, >=, <=, ==, !=
const match = condition.match(/^(\w+)\s*(>=|<=|==|!=|>|<)\s*(\S+)$/);
if (!match) return true;
const [, field, operator, value] = match;
const fieldValue = data[field];
if (fieldValue === undefined) return false;
const numValue = parseFloat(value);
const numFieldValue = parseFloat(fieldValue);
switch (operator) {
case '>': return numFieldValue > numValue;
case '<': return numFieldValue < numValue;
case '>=': return numFieldValue >= numValue;
case '<=': return numFieldValue <= numValue;
case '==': return fieldValue == value;
case '!=': return fieldValue != value;
default: return false;
}
}
/**
* Stop all scheduled workflows
*/
stop() {
for (const [workflowId] of this.scheduledJobs) {
this.stopScheduledWorkflow(workflowId);
}
console.log('[WorkflowEngine] All scheduled workflows stopped');
}
}
module.exports = { WorkflowEngine, BUNDLED_WORKFLOWS };
+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"
}
]
+501
View File
@@ -0,0 +1,501 @@
/**
* Notification Manager - Multi-provider notification delivery
* Supports Discord, Telegram, ntfy, and Email notifications
*/
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
const DEFAULT_CONFIG = {
enabled: true,
providers: {
discord: { enabled: false, webhookUrl: '' },
telegram: { enabled: false, botToken: '', chatId: '' },
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' }
},
events: {
'container-down': true,
'container-up': false,
'alert': true,
'backup-complete': true,
'backup-failed': true,
'update-available': true
}
};
class NotificationManager extends EventEmitter {
constructor(ctx) {
super();
this.ctx = ctx;
this.NOTIFICATIONS_FILE = ctx.NOTIFICATIONS_FILE;
this.log = ctx.log || console;
this.config = { ...DEFAULT_CONFIG };
this.lastSent = null;
this.history = [];
this.maxHistory = 100;
this.healthDaemonInterval = null;
this.healthState = new Map();
this._loadConfig();
}
/**
* Load config from file
*/
_loadConfig() {
try {
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
}
} catch (error) {
this.log.error('notification', 'Failed to load config', { error: error.message });
}
}
/**
* Merge loaded config with defaults
*/
_mergeConfig(defaults, loaded) {
const result = { ...defaults };
for (const key of Object.keys(defaults)) {
if (loaded && typeof defaults[key] === 'object' && !Array.isArray(defaults[key])) {
result[key] = { ...defaults[key], ...loaded[key] };
} else if (loaded && loaded[key] !== undefined) {
result[key] = loaded[key];
}
}
return result;
}
/**
* Get current config (for API)
*/
getConfig() {
return this.config;
}
/**
* Save config to file
*/
async saveConfig() {
try {
const dir = path.dirname(this.NOTIFICATIONS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.NOTIFICATIONS_FILE, JSON.stringify(this.config, null, 2));
return true;
} catch (error) {
this.log.error('notification', 'Failed to save config', { error: error.message });
throw error;
}
}
/**
* Get notification history
*/
getHistory() {
return this.history.slice();
}
/**
* Clear notification history
*/
clearHistory() {
this.history = [];
}
/**
* Add entry to history
*/
_addToHistory(entry) {
this.history.unshift({
...entry,
timestamp: new Date().toISOString()
});
if (this.history.length > this.maxHistory) {
this.history = this.history.slice(0, this.maxHistory);
}
this.lastSent = new Date().toISOString();
}
/**
* Send notification via all enabled providers
*/
async send(event, data, type = 'info') {
if (!this.config.enabled) {
return { success: false, error: 'Notifications disabled' };
}
// Check if event is enabled
if (event && this.config.events && !this.config.events[event]) {
return { success: false, error: `Event ${event} not enabled` };
}
const results = [];
const providers = this.config.providers;
// Discord
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
try {
const result = await this.sendDiscord(this._formatText(data, event), this._formatEmbed(data, event, type));
results.push({ provider: 'discord', ...result });
} catch (error) {
results.push({ provider: 'discord', success: false, error: error.message });
}
}
// Telegram
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
try {
const result = await this.sendTelegram(this._formatText(data, event));
results.push({ provider: 'telegram', ...result });
} catch (error) {
results.push({ provider: 'telegram', success: false, error: error.message });
}
}
// ntfy
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
try {
const result = await this.sendNtfy(this._formatText(data, event), this._formatTitle(event));
results.push({ provider: 'ntfy', ...result });
} catch (error) {
results.push({ provider: 'ntfy', success: false, error: error.message });
}
}
// Email
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
try {
const result = await this.sendEmail(
this._formatTitle(event),
this._formatText(data, event)
);
results.push({ provider: 'email', ...result });
} catch (error) {
results.push({ provider: 'email', success: false, error: error.message });
}
}
const allSucceeded = results.every(r => r.success);
this._addToHistory({
title: this._formatTitle(event),
type,
event,
results
});
return { success: allSucceeded, results };
}
/**
* Send Discord webhook notification
*/
async sendDiscord(text, embed) {
const { webhookUrl } = this.config.providers.discord;
if (!webhookUrl) {
throw new Error('Discord webhook not configured');
}
const payload = {
content: text,
embeds: embed ? [embed] : []
};
const response = await this.ctx.fetchT(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Discord API error: ${response.status}`);
}
return { success: true };
}
/**
* Send Telegram message
*/
async sendTelegram(text) {
const { botToken, chatId } = this.config.providers.telegram;
if (!botToken || !chatId) {
throw new Error('Telegram not configured');
}
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const payload = {
chat_id: chatId,
text,
parse_mode: 'Markdown'
};
const response = await this.ctx.fetchT(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.ok) {
throw new Error(`Telegram error: ${data.description}`);
}
return { success: true };
}
/**
* Send ntfy notification
*/
async sendNtfy(text, title) {
const { serverUrl, topic } = this.config.providers.ntfy;
if (!topic) {
throw new Error('ntfy topic not configured');
}
const url = `${serverUrl.replace(/\/$/, '')}/${topic}`;
const response = await this.ctx.fetchT(url, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'Title': title || 'DashCaddy',
'Priority': '3'
},
body: text
});
if (!response.ok) {
throw new Error(`ntfy error: ${response.status}`);
}
return { success: true };
}
/**
* Send email notification
*/
async sendEmail(subject, body) {
const { host, port, to, from, username, password, secure } = this.config.providers.email;
if (!host || !to) {
throw new Error('Email not configured');
}
// Create transporter
const transporter = nodemailer.createTransport({
host,
port: parseInt(port) || 587,
secure: !!secure,
auth: username ? {
user: username,
pass: password
} : undefined
});
// Send mail
await transporter.sendMail({
from: from || username,
to,
subject,
text: body,
html: `<pre style="font-family: monospace;">${body}</pre>`
});
return { success: true };
}
/**
* Send resource alert
*/
async sendAlert(alert) {
const text = this._formatAlertText(alert);
const embed = {
title: `⚠️ Resource Alert: ${alert.containerName}`,
color: this._getAlertColor(alert.alerts),
fields: alert.alerts.map(a => ({
name: a.type.toUpperCase(),
value: a.message,
inline: true
})),
footer: {
text: 'DashCaddy Resource Monitor'
},
timestamp: alert.timestamp
};
return this.send('alert', { ...alert, text, embed }, 'warning');
}
/**
* Send backup complete notification
*/
async sendBackupComplete(backup) {
const event = backup.status === 'success' ? 'backup-complete' : 'backup-failed';
const type = backup.status === 'success' ? 'success' : 'error';
const text = backup.status === 'success'
? `✅ Backup "${backup.name}" completed successfully`
: `❌ Backup "${backup.name}" failed: ${backup.error}`;
return this.send(event, { ...backup, text }, type);
}
/**
* Send service event notification (container up/down, deploy success/fail)
*/
async sendServiceEvent(event, service) {
const eventMap = {
'container-up': { type: 'success', text: `✅ Container "${service.containerName || service.name}" is now UP` },
'container-down': { type: 'error', text: `🔴 Container "${service.containerName || service.name}" is DOWN` },
'deploy-success': { type: 'success', text: `✅ "${service.name}" deployed successfully` },
'deploy-failed': { type: 'error', text: `❌ "${service.name}" deployment failed` },
'auto-restart': { type: 'warning', text: `🔄 Container "${service.containerName || service.name}" auto-restarted` }
};
const info = eventMap[event] || { type: 'info', text: `Service event: ${event}` };
return this.send(event, { ...service, text: info.text }, info.type);
}
// ===== Helper Methods =====
_formatTitle(event) {
const titles = {
'container-down': 'Container Down',
'container-up': 'Container Recovered',
'alert': 'Resource Alert',
'backup-complete': 'Backup Complete',
'backup-failed': 'Backup Failed',
'update-available': 'Update Available',
'test': 'Test Notification',
'auto-restart': 'Auto-Restart',
'deploy-success': 'Deployment Success',
'deploy-failed': 'Deployment Failed'
};
return titles[event] || 'DashCaddy Notification';
}
_formatText(data, event) {
if (typeof data === 'string') return data;
return data.text || data.message || this._formatTitle(event);
}
_formatEmbed(data, event, type) {
if (typeof data === 'string') return null;
if (data.embed) return data.embed;
return {
title: this._formatTitle(event),
description: data.text || data.message || '',
color: this._getTypeColor(type),
timestamp: new Date().toISOString()
};
}
_formatAlertText(alert) {
const lines = [
`**${alert.containerName}**`,
'',
...alert.alerts.map(a => `${a.message}`)
];
return lines.join('\n');
}
_getAlertColor(alerts) {
if (alerts.some(a => a.severity === 'critical')) return 15158332; // Red
if (alerts.some(a => a.severity === 'warning')) return 16776960; // Yellow
return 3447003; // Blue
}
_getTypeColor(type) {
const colors = {
success: 3066993, // Green
error: 15158332, // Red
warning: 16776960, // Yellow
info: 3447003 // Blue
};
return colors[type] || colors.info;
}
// ===== Health Check Daemon =====
startHealthDaemon() {
if (this.healthDaemonInterval) return;
const interval = (this.config.healthCheck?.intervalMinutes || 5) * 60 * 1000;
this.healthDaemonInterval = setInterval(() => {
this.checkHealth().catch(err => {
this.log.error('notification', 'Health check failed', { error: err.message });
});
}, interval);
this.log.info('notification', 'Health daemon started', { intervalMinutes: this.config.healthCheck?.intervalMinutes });
}
stopHealthDaemon() {
if (this.healthDaemonInterval) {
clearInterval(this.healthDaemonInterval);
this.healthDaemonInterval = null;
this.log.info('notification', 'Health daemon stopped');
}
}
async checkHealth() {
if (!this.config.healthCheck?.enabled || !this.ctx.docker) {
return { checked: false };
}
try {
const containers = await this.ctx.docker.listContainers({ all: true });
const previousState = new Map(this.healthState);
for (const container of containers) {
const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
const wasDown = previousState.get(container.Id) === false;
const isDown = container.State !== 'running';
this.healthState.set(container.Id, isDown);
if (wasDown && !isDown) {
// Container recovered
await this.sendServiceEvent('container-up', {
containerId: container.Id,
containerName: name,
state: container.State
});
} else if (!wasDown && isDown) {
// Container went down
await this.sendServiceEvent('container-down', {
containerId: container.Id,
containerName: name,
state: container.State
});
}
}
// Update last check time
this.config.healthCheck = this.config.healthCheck || {};
this.config.healthCheck.lastCheck = new Date().toISOString();
await this.saveConfig();
return {
checked: true,
containersMonitored: containers.length,
lastCheck: this.config.healthCheck.lastCheck
};
} catch (error) {
this.log.error('notification', 'Health check error', { error: error.message });
throw error;
}
}
getHealthState() {
return new Map(this.healthState);
}
}
module.exports = NotificationManager;
+152 -3
View File
@@ -16,6 +16,7 @@ const STATS_FILE = process.env.STATS_FILE || path.join(__dirname, 'container-sta
const STATS_HOURLY_FILE = process.env.STATS_HOURLY_FILE || path.join(__dirname, 'container-stats-hourly.json'); const STATS_HOURLY_FILE = process.env.STATS_HOURLY_FILE || path.join(__dirname, 'container-stats-hourly.json');
const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(__dirname, 'container-stats-daily.json'); const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(__dirname, 'container-stats-daily.json');
const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(__dirname, 'alert-config.json'); const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(__dirname, 'alert-config.json');
const ALERT_HISTORY_FILE = process.env.ALERT_HISTORY_FILE || path.join(__dirname, 'alert-history.json');
const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw
const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly
const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily
@@ -35,11 +36,21 @@ class ResourceMonitor extends EventEmitter {
this.dailyHistory = new Map(); // containerId -> { name, samples: [...] } (daily avg, 365d) this.dailyHistory = new Map(); // containerId -> { name, samples: [...] } (daily avg, 365d)
this.alerts = new Map(); // containerId -> alert config this.alerts = new Map(); // containerId -> alert config
this.lastAlerts = new Map(); // containerId -> last alert timestamp this.lastAlerts = new Map(); // containerId -> last alert timestamp
this.alertHistory = []; // alert history entries
this.notificationManager = null;
this.loadStats(); this.loadStats();
this.loadHourlyStats(); this.loadHourlyStats();
this.loadDailyStats(); this.loadDailyStats();
this.loadAlertConfig(); this.loadAlertConfig();
this.loadAlertHistory();
}
/**
* Set the notification manager for sending alerts
*/
setNotificationManager(nm) {
this.notificationManager = nm;
} }
/** /**
@@ -285,20 +296,58 @@ class ResourceMonitor extends EventEmitter {
if (alerts.length > 0) { if (alerts.length > 0) {
this.lastAlerts.set(containerId, now); this.lastAlerts.set(containerId, now);
this.emit('alert', { // Add alert history entries
for (const alert of alerts) {
this.addAlertHistoryEntry({
id: `${containerId}-${Date.now()}-${alert.type}`,
timestamp: new Date().toISOString(),
containerId,
containerName,
type: alert.type,
metric: alert.type,
value: alert.value,
threshold: alert.threshold,
severity: alert.severity,
notified: !!this.notificationManager,
autoRestartTriggered: !!alertConfig.autoRestart
});
}
const alertPayload = {
containerId, containerId,
containerName, containerName,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
alerts, alerts,
stats, stats,
config: alertConfig config: alertConfig
}); };
this.emit('alert', alertPayload);
// Send notification if manager is configured
if (this.notificationManager) {
this.notificationManager.sendAlert(alertPayload).catch(err => {
console.error('[ResourceMonitor] Failed to send alert notification:', err.message);
});
}
// Auto-restart if configured // Auto-restart if configured
if (alertConfig.autoRestart) { if (alertConfig.autoRestart) {
this.restartContainer(containerId, containerName, alerts); this.restartContainer(containerId, containerName, alerts);
} }
// Trigger bundled workflows for resource-alert
this.triggerWorkflows('resource-alert', {
containerId,
containerName,
alerts,
stats,
diskPercent: (stats.disk?.readBytes + stats.disk?.writeBytes) > 0
? Math.round((stats.disk.readBytes / (stats.disk.readBytes + stats.disk.writeBytes)) * 100)
: 0,
host: require('os').hostname()
});
} }
} }
@@ -318,11 +367,55 @@ class ResourceMonitor extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
reason: alerts reason: alerts
}); });
// Send notification if manager is configured
if (this.notificationManager) {
this.notificationManager.send('auto-restart', {
containerId,
containerName,
timestamp: new Date().toISOString(),
reason: alerts
}).catch(err => {
console.error('[ResourceMonitor] Failed to send auto-restart notification:', err.message);
});
}
} catch (error) { } catch (error) {
console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message); console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message);
} }
} }
/**
* Trigger bundled workflows for an event
*/
triggerWorkflows(eventType, eventData) {
if (!this.workflowEngine) {
console.log('[ResourceMonitor] Workflow engine not set, skipping workflow trigger');
return;
}
try {
this.workflowEngine.triggerForEvent(eventType, eventData)
.then(results => {
if (results && results.length > 0) {
console.log(`[ResourceMonitor] Triggered ${results.length} workflow(s) for ${eventType}`);
}
})
.catch(err => {
console.error('[ResourceMonitor] Workflow trigger error:', err.message);
});
} catch (error) {
console.error('[ResourceMonitor] Error triggering workflows:', error.message);
}
}
/**
* Set the workflow engine for triggering workflows
*/
setWorkflowEngine(workflowEngine) {
this.workflowEngine = workflowEngine;
console.log('[ResourceMonitor] Workflow engine configured');
}
/** /**
* Get current stats for a container * Get current stats for a container
*/ */
@@ -430,6 +523,62 @@ class ResourceMonitor extends EventEmitter {
this.saveAlertConfig(); this.saveAlertConfig();
} }
/**
* Get all alert configurations
*/
getAllAlertConfigs() {
const configs = {};
for (const [containerId, config] of this.alerts.entries()) {
configs[containerId] = config;
}
return configs;
}
/**
* Add entry to alert history
*/
addAlertHistoryEntry(entry) {
this.alertHistory.unshift(entry);
// Keep only last 1000 entries
if (this.alertHistory.length > 1000) {
this.alertHistory = this.alertHistory.slice(0, 1000);
}
this.saveAlertHistory();
}
/**
* Get alert history
*/
getAlertHistory(limit = 50) {
return this.alertHistory.slice(0, limit);
}
/**
* Load alert history from disk
*/
loadAlertHistory() {
try {
if (fs.existsSync(ALERT_HISTORY_FILE)) {
const data = JSON.parse(fs.readFileSync(ALERT_HISTORY_FILE, 'utf8'));
this.alertHistory = Array.isArray(data) ? data : [];
console.log(`[ResourceMonitor] Loaded ${this.alertHistory.length} alert history entries`);
}
} catch (error) {
console.error('[ResourceMonitor] Error loading alert history:', error.message);
}
}
/**
* Save alert history to disk
*/
saveAlertHistory() {
try {
fs.writeFileSync(ALERT_HISTORY_FILE, JSON.stringify(this.alertHistory, null, 2));
} catch (error) {
console.error('[ResourceMonitor] Error saving alert history:', error.message);
}
}
/** /**
* Cleanup old stats beyond retention period * Cleanup old stats beyond retention period
*/ */
+1 -1
View File
@@ -55,7 +55,7 @@ module.exports = function(ctx) {
try { router.use('/apps', initTemplates(subCtx)); } try { router.use('/apps', initTemplates(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); } catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
try { router.use('/restore', initRestore(subCtx)); } try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); } catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
try { router.use('/compose', initCompose(subCtx)); } try { router.use('/compose', initCompose(subCtx)); }
+187
View File
@@ -1,6 +1,10 @@
const express = require('express'); const express = require('express');
const path = require('path');
const fs = require('fs');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
/** /**
* Apps restore routes factory * Apps restore routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -122,6 +126,180 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
res.json({ success: true, services: status }); res.json({ success: true, services: status });
}, 'apps-restore-status')); }, 'apps-restore-status'));
// ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ====================
// Get available backup files for a specific app
router.get('/:appId/backup-points', asyncHandler(async (req, res) => {
const { appId } = req.params;
const backupDir = DEFAULT_BACKUP_DIR;
const files = [];
try {
if (fs.existsSync(backupDir)) {
const entries = fs.readdirSync(backupDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.backup')) {
try {
const nameWithoutExt = entry.name.replace('.backup', '');
const parts = nameWithoutExt.split('-');
const fileAppId = parts[0];
// Only include files for the requested app
if (fileAppId !== appId) continue;
const filepath = path.join(backupDir, entry.name);
const stats = fs.statSync(filepath);
const timestamp = parts.length > 1 ? parseInt(parts[parts.length - 1]) : stats.mtimeMs;
files.push({
name: entry.name,
appId: fileAppId,
size: stats.size,
sizeFormatted: formatBytes(stats.size),
timestamp: new Date(timestamp).toISOString(),
modified: stats.mtime.toISOString(),
path: filepath
});
} catch (err) {
// Skip malformed filenames
}
}
}
}
} catch (err) {
// Directory might not exist yet
}
// Sort by timestamp descending (newest first)
files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
res.json({
success: true,
appId,
isBackupFile: true,
files,
total: files.length
});
}, 'apps-backup-points'));
// Revert a specific app to a backup file (point-in-time restore)
router.post('/:appId/revert/:filename', asyncHandler(async (req, res) => {
const { appId, filename } = req.params;
const { encryptionKey, restartContainers } = req.body || {};
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ success: false, error: 'Invalid filename' });
}
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: `Backup file not found: ${filename}` });
}
try {
// Read the backup file
let fileData = fs.readFileSync(filepath);
// Decrypt if needed
if (encryptionKey) {
try {
fileData = await backupManager.decryptBackup(fileData, encryptionKey);
} catch (err) {
return res.status(400).json({ success: false, error: 'Failed to decrypt backup: ' + err.message });
}
}
// Decompress
const backupData = await backupManager.decompressBackup(fileData);
// Extract to temp directory
const os = require('os');
const crypto = require('crypto');
const tempDir = path.join(os.tmpdir(), `dashcaddy-revert-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
fs.mkdirSync(tempDir, { recursive: true });
try {
const tarPath = path.join(tempDir, 'backup.tar.gz');
fs.writeFileSync(tarPath, backupData);
const { execSync } = require('child_process');
try {
execSync(`cd "${tempDir}" && tar -xzf backup.tar.gz`, { stdio: 'pipe' });
} catch (tarErr) {
throw new Error('Failed to extract backup archive: ' + tarErr.message);
}
// Read manifest if present
let manifest = null;
const manifestPath = path.join(tempDir, 'manifest.json');
if (fs.existsSync(manifestPath)) {
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (_) {}
}
// Read app-specific data
const appServicesPath = path.join(tempDir, 'services.json');
const appConfigPath = path.join(tempDir, 'config.json');
const appCredsPath = path.join(tempDir, 'credentials.json');
let restoreData = { services: null, config: null, credentials: null };
if (fs.existsSync(appServicesPath)) {
try { restoreData.services = JSON.parse(fs.readFileSync(appServicesPath, 'utf8')); } catch (_) {}
}
if (fs.existsSync(appConfigPath)) {
try { restoreData.config = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')); } catch (_) {}
}
if (fs.existsSync(appCredsPath)) {
try { restoreData.credentials = JSON.parse(fs.readFileSync(appCredsPath, 'utf8')); } catch (_) {}
}
// If restartContainers is true, actually perform the restore
if (restartContainers) {
if (restoreData.services) backupManager.restoreServices(restoreData.services);
if (restoreData.config) backupManager.restoreConfig(restoreData.config);
if (restoreData.credentials) backupManager.restoreCredentials(restoreData.credentials);
// Cleanup temp dir
fs.rmSync(tempDir, { recursive: true, force: true });
res.json({
success: true,
isBackupFile: true,
restored: {
services: !!restoreData.services,
config: !!restoreData.config,
credentials: !!restoreData.credentials
},
message: `${appId} reverted to backup successfully`
});
} else {
// Preview mode
fs.rmSync(tempDir, { recursive: true, force: true });
res.json({
success: true,
isBackupFile: true,
preview: true,
filename,
appId,
manifest,
restoreData: {
hasServices: !!restoreData.services,
hasConfig: !!restoreData.config,
hasCredentials: !!restoreData.credentials
}
});
}
} catch (err) {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {}
throw err;
}
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}, 'apps-revert'));
/** /**
* Core restore logic for a single service. * Core restore logic for a single service.
*/ */
@@ -309,3 +487,12 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
return router; return router;
}; };
// Helper: format bytes to human readable
function formatBytes(bytes) {
if (bytes === 0) 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];
}
+500 -1
View File
@@ -1,16 +1,470 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../response-helpers');
const fs = require('fs');
const path = require('path');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
/** /**
* Backups routes factory * Backups routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
* @param {Object} deps.backupManager - Backup management module * @param {Object} deps.backupManager - Backup management module
* @param {Object} deps.licenseManager - License manager for premium gating
* @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.asyncHandler - Async route handler wrapper
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ backupManager, asyncHandler }) { module.exports = function({ backupManager, licenseManager, asyncHandler }) {
const router = express.Router(); const router = express.Router();
// ==================== SCHEDULE ENDPOINTS (PREMIUM) ====================
// Apply premium gating to schedule-related routes
const premiumGating = licenseManager.requirePremium('auto-backup');
// Get all scheduled backup configs
router.get('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
const config = backupManager.getConfig();
const backups = config.backups || {};
// Calculate next run times based on schedule and last history entry
const history = backupManager.getHistory(1000);
const schedules = Object.entries(backups).map(([appId, backup]) => {
const appHistory = history.filter(h => h.name === appId && h.status === 'success');
const lastRun = appHistory.length > 0 ? new Date(appHistory[0].timestamp) : null;
const nextRun = calculateNextRun(lastRun, backup.schedule);
return {
appId,
enabled: backup.enabled || false,
schedule: backup.schedule || 'daily',
retention: backup.retention || { keep: 7, olderThan: null },
runImmediately: backup.runImmediately || false,
destination: backup.destination || 'local',
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
lastRun: lastRun ? lastRun.toISOString() : null,
nextRun: nextRun ? nextRun.toISOString() : null,
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
};
});
success(res, { schedules });
}, 'backups-schedule-list'));
// Create or update a scheduled backup for an app
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
if (!appId) {
const { ValidationError } = require('../errors');
throw new ValidationError('appId is required');
}
const config = backupManager.getConfig();
if (!config.backups) config.backups = {};
// Build the backup config for this app
const backupConfig = {
enabled: enabled !== undefined ? enabled : true,
schedule: schedule || 'daily',
retention: retention || { keep: 7, olderThan: null },
runImmediately: runImmediately || false,
destination: destination || 'local',
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
include: ['all'],
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
};
config.backups[appId] = backupConfig;
backupManager.updateConfig(config);
success(res, {
message: `Backup schedule ${enabled === false ? 'disabled' : 'updated'} for ${appId}`,
schedule: {
appId,
...backupConfig,
retention: backupConfig.retention
}
});
}, 'backups-schedule-update'));
// Remove scheduled backup for an app
router.delete('/backups/schedule/:appId', premiumGating, asyncHandler(async (req, res) => {
const { appId } = req.params;
const config = backupManager.getConfig();
if (!config.backups || !config.backups[appId]) {
const { NotFoundError } = require('../errors');
throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404');
}
delete config.backups[appId];
backupManager.updateConfig(config);
success(res, { message: `Backup schedule removed for ${appId}` });
}, 'backups-schedule-delete'));
// List backup files on disk
router.get('/backups/files', asyncHandler(async (req, res) => {
const backupDir = DEFAULT_BACKUP_DIR;
const files = [];
try {
if (fs.existsSync(backupDir)) {
const entries = fs.readdirSync(backupDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.backup')) {
try {
const filepath = path.join(backupDir, entry.name);
const stats = fs.statSync(filepath);
const nameWithoutExt = entry.name.replace('.backup', '');
const parts = nameWithoutExt.split('-');
const appId = parts[0];
const timestamp = parts.length > 1 ? parseInt(parts[parts.length - 1]) : stats.mtimeMs;
files.push({
name: entry.name,
appId,
size: stats.size,
sizeFormatted: formatBytes(stats.size),
timestamp: new Date(timestamp).toISOString(),
path: filepath
});
} catch (err) {
// Skip malformed filenames
}
}
}
}
} catch (err) {
// Directory might not exist yet
}
// Sort by timestamp descending (newest first)
files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
success(res, { files, total: files.length });
}, 'backups-files-list'));
// Trigger immediate backup for an app
router.post('/backups/backup/:appId', asyncHandler(async (req, res) => {
const { appId } = req.params;
const config = backupManager.getConfig();
const backupConfig = config.backups && config.backups[appId];
if (!backupConfig) {
const { NotFoundError } = require('../errors');
throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404');
}
const backup = await backupManager.executeBackup(appId, {
...backupConfig,
destinations: backupConfig.destinations || [{ type: backupConfig.destination || 'local', path: backupConfig.destinationPath || DEFAULT_BACKUP_DIR }]
});
success(res, {
message: `Backup started for ${appId}`,
backup: {
id: backup.id,
name: backup.name,
timestamp: backup.timestamp,
size: backup.size,
status: backup.status
}
});
}, 'backups-backup-trigger'));
// List backup files for a specific app
router.get('/backups/files/:appId', asyncHandler(async (req, res) => {
const { appId } = req.params;
const backupDir = DEFAULT_BACKUP_DIR;
const files = [];
try {
if (fs.existsSync(backupDir)) {
const entries = fs.readdirSync(backupDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.backup')) {
try {
const nameWithoutExt = entry.name.replace('.backup', '');
const parts = nameWithoutExt.split('-');
const fileAppId = parts[0];
// Only include files for the requested app
if (fileAppId !== appId) continue;
const filepath = path.join(backupDir, entry.name);
const stats = fs.statSync(filepath);
const timestamp = parts.length > 1 ? parseInt(parts[parts.length - 1]) : stats.mtimeMs;
files.push({
name: entry.name,
appId: fileAppId,
size: stats.size,
sizeFormatted: formatBytes(stats.size),
timestamp: new Date(timestamp).toISOString(),
path: filepath
});
} catch (err) {
// Skip malformed filenames
}
}
}
}
} catch (err) {
// Directory might not exist yet
}
// Sort by timestamp descending (newest first)
files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
success(res, { files, total: files.length });
}, 'backups-files-app'));
// Restore from a specific backup file on disk
router.post('/backups/restore-file/:filename', asyncHandler(async (req, res) => {
const { filename } = req.params;
const { encryptionKey, restartContainers } = req.body || {};
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
const { ValidationError } = require('../errors');
throw new ValidationError('Invalid filename');
}
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
if (!fs.existsSync(filepath)) {
const { NotFoundError } = require('../errors');
throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404');
}
// Read the backup file
let fileData = fs.readFileSync(filepath);
// Decrypt if needed (format: iv:authTag:encrypted base64)
if (encryptionKey) {
try {
fileData = await backupManager.decryptBackup(fileData, encryptionKey);
} catch (err) {
throw new Error('Failed to decrypt backup: ' + err.message);
}
}
// Decompress
const backupData = await backupManager.decompressBackup(fileData);
// Extract to temp directory for inspection/restoration
const os = require('os');
const crypto = require('crypto');
const tempDir = path.join(os.tmpdir(), `dashcaddy-restore-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
fs.mkdirSync(tempDir, { recursive: true });
try {
// Write the decompressed JSON as a tar.gz to extract
const tarPath = path.join(tempDir, 'backup.tar.gz');
fs.writeFileSync(tarPath, backupData);
// Extract tar.gz
const { execSync } = require('child_process');
try {
execSync(`cd "${tempDir}" && tar -xzf backup.tar.gz`, { stdio: 'pipe' });
} catch (tarErr) {
throw new Error('Failed to extract backup archive: ' + tarErr.message);
}
// Read manifest if present
const manifestPath = path.join(tempDir, 'manifest.json');
let manifest = null;
if (fs.existsSync(manifestPath)) {
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (_) { /* ignore malformed manifest */ }
}
// Read extracted data files
const restoreData = {
services: null,
config: null,
credentials: null,
volumes: null
};
const servicesPath = path.join(tempDir, 'services.json');
if (fs.existsSync(servicesPath)) {
try { restoreData.services = JSON.parse(fs.readFileSync(servicesPath, 'utf8')); } catch (_) {}
}
const configPath = path.join(tempDir, 'config.json');
if (fs.existsSync(configPath)) {
try { restoreData.config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (_) {}
}
const credsPath = path.join(tempDir, 'credentials.json');
if (fs.existsSync(credsPath)) {
try { restoreData.credentials = JSON.parse(fs.readFileSync(credsPath, 'utf8')); } catch (_) {}
}
const volumesPath = path.join(tempDir, 'volumes.json');
if (fs.existsSync(volumesPath)) {
try { restoreData.volumes = JSON.parse(fs.readFileSync(volumesPath, 'utf8')); } catch (_) {}
}
// If restartContainers is true, actually perform the restore
if (restartContainers) {
if (restoreData.services) {
backupManager.restoreServices(restoreData.services);
}
if (restoreData.config) {
backupManager.restoreConfig(restoreData.config);
}
if (restoreData.credentials) {
backupManager.restoreCredentials(restoreData.credentials);
}
if (restoreData.volumes) {
await backupManager.restoreVolumes(restoreData.volumes);
}
// Cleanup temp dir
fs.rmSync(tempDir, { recursive: true, force: true });
success(res, {
restored: {
services: !!restoreData.services,
config: !!restoreData.config,
credentials: !!restoreData.credentials,
volumes: !!restoreData.volumes
},
message: 'Backup restored successfully'
});
} else {
// Preview mode: return what would be restored
fs.rmSync(tempDir, { recursive: true, force: true });
success(res, {
preview: true,
filename,
size: fs.statSync(filepath).size,
sizeFormatted: formatBytes(fs.statSync(filepath).size),
manifest,
restoreData: {
hasServices: !!restoreData.services,
hasConfig: !!restoreData.config,
hasCredentials: !!restoreData.credentials,
hasVolumes: !!restoreData.volumes
}
});
}
} catch (err) {
// Cleanup on error
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {}
throw err;
}
}, 'backups-restore-file'));
// Compare a backup file against current state
router.post('/backups/compare/:filename', asyncHandler(async (req, res) => {
const { filename } = req.params;
const { encryptionKey } = req.body || {};
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
const { ValidationError } = require('../errors');
throw new ValidationError('Invalid filename');
}
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
if (!fs.existsSync(filepath)) {
const { NotFoundError } = require('../errors');
throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404');
}
// Read the backup file
let fileData = fs.readFileSync(filepath);
// Decrypt if needed
if (encryptionKey) {
try {
fileData = await backupManager.decryptBackup(fileData, encryptionKey);
} catch (err) {
throw new Error('Failed to decrypt backup: ' + err.message);
}
}
// Decompress
const backupData = await backupManager.decompressBackup(fileData);
// Extract to temp directory
const os = require('os');
const crypto = require('crypto');
const tempDir = path.join(os.tmpdir(), `dashcaddy-compare-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
fs.mkdirSync(tempDir, { recursive: true });
try {
const tarPath = path.join(tempDir, 'backup.tar.gz');
fs.writeFileSync(tarPath, backupData);
const { execSync } = require('child_process');
try {
execSync(`cd "${tempDir}" && tar -xzf backup.tar.gz`, { stdio: 'pipe' });
} catch (tarErr) {
throw new Error('Failed to extract backup archive: ' + tarErr.message);
}
// Build diff
const diff = {
filename,
timestamp: fs.statSync(filepath).mtime.toISOString(),
size: fs.statSync(filepath).size,
sizeFormatted: formatBytes(fs.statSync(filepath).size),
services: null,
config: null
};
// Compare services.json
const servicesPath = path.join(tempDir, 'services.json');
if (fs.existsSync(servicesPath)) {
try {
const backupServices = JSON.parse(fs.readFileSync(servicesPath, 'utf8'));
const currentServicesPath = process.env.SERVICES_FILE || path.join(__dirname, 'services.json');
let currentServices = null;
if (fs.existsSync(currentServicesPath)) {
currentServices = JSON.parse(fs.readFileSync(currentServicesPath, 'utf8'));
}
diff.services = {
backup: backupServices,
current: currentServices,
hasChanges: JSON.stringify(backupServices) !== JSON.stringify(currentServices),
backupCount: Array.isArray(backupServices) ? backupServices.length : 0,
currentCount: Array.isArray(currentServices) ? currentServices.length : 0
};
} catch (_) {}
}
// Compare config.json
const configPath = path.join(tempDir, 'config.json');
if (fs.existsSync(configPath)) {
try {
const backupConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const currentConfigPath = process.env.CONFIG_FILE || path.join(__dirname, 'config.json');
let currentConfig = null;
if (fs.existsSync(currentConfigPath)) {
currentConfig = JSON.parse(fs.readFileSync(currentConfigPath, 'utf8'));
}
diff.config = {
backup: backupConfig,
current: currentConfig,
hasChanges: JSON.stringify(backupConfig) !== JSON.stringify(currentConfig)
};
} catch (_) {}
}
fs.rmSync(tempDir, { recursive: true, force: true });
success(res, { diff });
} catch (err) {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {}
throw err;
}
}, 'backups-compare'));
// ==================== EXISTING ENDPOINTS ====================
// Get backup configuration // Get backup configuration
router.get('/backups/config', asyncHandler(async (req, res) => { router.get('/backups/config', asyncHandler(async (req, res) => {
const config = backupManager.getConfig(); const config = backupManager.getConfig();
@@ -154,3 +608,48 @@ module.exports = function({ backupManager, asyncHandler }) {
return router; return router;
}; };
// Helper functions
/**
* Calculate next run time based on schedule and last run
*/
function calculateNextRun(lastRun, schedule) {
if (!lastRun) return null;
const intervals = {
'hourly': 60 * 60 * 1000,
'daily': 24 * 60 * 60 * 1000,
'weekly': 7 * 24 * 60 * 60 * 1000,
'monthly': 30 * 24 * 60 * 60 * 1000
};
const baseInterval = intervals[schedule];
if (baseInterval) {
return new Date(lastRun.getTime() + baseInterval);
}
// Custom interval (e.g., "6h", "30m", "6" for 6 minutes)
const match = schedule.match(/^(\d+)([mh])?$/);
if (match) {
const value = parseInt(match[1]);
const unit = match[2] || 'm'; // default to minutes
const ms = unit === 'h' ? value * 60 * 60 * 1000 : value * 60 * 1000;
return new Date(lastRun.getTime() + ms);
}
// Default to daily
return new Date(lastRun.getTime() + intervals.daily);
}
/**
* Format bytes to human readable string
*/
function formatBytes(bytes) {
if (bytes === 0) 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];
}
+16 -5
View File
@@ -10,9 +10,10 @@ const { success } = require('../response-helpers');
* @param {Object} deps.docker - Docker client wrapper (client, pull methods) * @param {Object} deps.docker - Docker client wrapper (client, pull methods)
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.workflowEngine - WorkflowEngine instance (optional)
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ docker, log, asyncHandler }) { module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
const router = express.Router(); const router = express.Router();
// Helper: verify container exists before operating on it // Helper: verify container exists before operating on it
@@ -66,6 +67,11 @@ module.exports = function({ docker, log, asyncHandler }) {
log.info('docker', `Pulling latest image: ${imageName}`); log.info('docker', `Pulling latest image: ${imageName}`);
await docker.pull(imageName); await docker.pull(imageName);
// Trigger pre-update workflow (backup before update)
if (workflowEngine) {
try { await workflowEngine.triggerEvent('pre-update', { containerId: containerId, containerName, imageName }); } catch (w) { log.warn('workflow', 'pre-update trigger failed: ' + w.message); }
}
// Get current container config for recreation // Get current container config for recreation
const hostConfig = containerInfo.HostConfig; const hostConfig = containerInfo.HostConfig;
const config = { const config = {
@@ -135,10 +141,15 @@ module.exports = function({ docker, log, asyncHandler }) {
} }
success(res, { success(res, {
message: `Container ${containerName} updated successfully`, message: `Container ${containerName} updated successfully`,
newContainerId: newContainerInfo.Id newContainerId: newContainerInfo.Id
}); });
}, 'container-update'));
// Trigger post-update workflow
if (workflowEngine) {
try { await workflowEngine.triggerEvent('post-update', { containerId: containerId, containerName, imageName, newContainerId: newContainerInfo.Id }); } catch (w) { log.warn('workflow', 'post-update trigger failed: ' + w.message); }
}
}, 'container-update'));
// Check for available updates (compares local and remote image digests) // Check for available updates (compares local and remote image digests)
router.get('/:id/check-update', asyncHandler(async (req, res) => { router.get('/:id/check-update', asyncHandler(async (req, res) => {
+81 -2
View File
@@ -10,7 +10,7 @@ const { success } = require('../response-helpers');
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ resourceMonitor, docker, asyncHandler, log }) { module.exports = function({ resourceMonitor, docker, asyncHandler, log, notificationManager }) {
const router = express.Router(); const router = express.Router();
// ===== RESOURCE MONITORING ENDPOINTS ===== // ===== RESOURCE MONITORING ENDPOINTS =====
@@ -66,7 +66,86 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log }) {
success(res, { aggregated, hours }); success(res, { aggregated, hours });
}, 'monitoring-aggregated')); }, 'monitoring-aggregated'));
// Configure alerts // ===== ALERT CONFIGURATION (bulk) =====
// Get all alert configs
router.get('/monitoring/alerts/config', asyncHandler(async (req, res) => {
const configs = resourceMonitor.getAllAlertConfigs();
success(res, { configs });
}, 'monitoring-alerts-config-get'));
// Set all alert configs (bulk update)
router.post('/monitoring/alerts/config', asyncHandler(async (req, res) => {
const { configs } = req.body;
if (!configs || typeof configs !== 'object') {
const { ValidationError } = require('../errors');
throw new ValidationError('configs object required');
}
for (const [containerId, config] of Object.entries(configs)) {
resourceMonitor.setAlertConfig(containerId, config);
}
success(res, { message: 'Alert configurations saved' });
}, 'monitoring-alerts-config-set'));
// Get alert history
router.get('/monitoring/alerts', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const history = resourceMonitor.getAlertHistory(limit);
success(res, { history });
}, 'monitoring-alerts-history'));
// Send test alert notification for a container
router.post('/monitoring/alerts/:containerId/test', asyncHandler(async (req, res) => {
const { containerId } = req.params;
// Get container name from docker
let containerName = containerId;
try {
const containers = await docker.client.listContainers({ all: false });
const containerInfo = containers.find(c => c.Id === containerId || c.Id.startsWith(containerId));
if (containerInfo) {
containerName = containerInfo.Names[0]?.replace(/^\//, '') || containerId;
}
} catch (_) {}
const testAlert = {
containerId,
containerName,
timestamp: new Date().toISOString(),
alerts: [{
type: 'test',
severity: 'info',
message: 'This is a test alert notification',
value: 0,
threshold: 0
}],
stats: null,
config: resourceMonitor.getAlertConfig(containerId) || {}
};
if (notificationManager) {
await notificationManager.sendAlert(testAlert);
}
// Also log to alert history
resourceMonitor.addAlertHistoryEntry({
id: `test-${Date.now()}`,
timestamp: new Date().toISOString(),
containerId,
containerName,
type: 'test',
metric: 'test',
value: 0,
threshold: 0,
severity: 'info',
notified: true,
autoRestartTriggered: false
});
success(res, { message: 'Test alert sent', alert: testAlert });
}, 'monitoring-alerts-test'));
// Configure alerts for a container
router.post('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { router.post('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => {
resourceMonitor.setAlertConfig(req.params.containerId, req.body); resourceMonitor.setAlertConfig(req.params.containerId, req.body);
success(res, { message: 'Alert configuration saved' }); success(res, { message: 'Alert configuration saved' });
+41
View File
@@ -218,5 +218,46 @@ module.exports = function({ notification, asyncHandler }) {
}); });
}, 'notifications-health-check')); }, 'notifications-health-check'));
// GET /status — Get notification system status
router.get('/status', asyncHandler(async (req, res) => {
const notificationConfig = notification.getConfig();
const providers = notificationConfig.providers || {};
res.json({
success: true,
enabled: notificationConfig.enabled,
providers: {
discord: providers.discord?.enabled && !!providers.discord?.webhookUrl,
telegram: providers.telegram?.enabled && !!providers.telegram?.botToken && !!providers.telegram?.chatId,
ntfy: providers.ntfy?.enabled && !!providers.ntfy?.topic,
email: providers.email?.enabled && !!providers.email?.host && !!providers.email?.to
},
lastSent: notification.lastSent,
healthCheck: notificationConfig.healthCheck?.enabled ? {
enabled: true,
lastCheck: notificationConfig.healthCheck.lastCheck,
intervalMinutes: notificationConfig.healthCheck.intervalMinutes
} : { enabled: false }
});
}, 'notifications-status'));
// POST /send — Manual test send (used by frontend "Send Test" button)
router.post('/send', asyncHandler(async (req, res) => {
const { event, data, type } = req.body;
if (!event) {
throw new ValidationError('Event type is required');
}
// Use 'test' as the event for manual sends
const result = await notification.send(event, data || {}, type || 'info');
res.json({
success: result.success,
event,
results: result.results
});
}, 'notifications-send'));
return router; return router;
}; };
+272
View File
@@ -0,0 +1,272 @@
const express = require('express');
const http = require('http');
/**
* OpenClaw management routes
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
*
* GET /openclaw/status container info + gateway health
* POST /openclaw/deploy deploy OpenClaw container
* GET /openclaw/proxy/* proxy GET to gateway
* POST /openclaw/proxy/* proxy POST to gateway
* DELETE /openclaw remove container
*/
module.exports = function openClawRoutes(ctx) {
const router = express.Router();
const docker = ctx.docker;
const asyncHandler = ctx.asyncHandler;
const log = ctx.log || console;
// ── helpers ──────────────────────────────────────────────────────────────
async function findOpenClawContainer() {
const containers = await docker.client.listContainers({ all: true });
return containers.find(function(c) {
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
c.Names.some(function(n) { return n.includes('openclaw'); }));
}) || null;
}
async function getGatewayToken(containerId) {
try {
const info = await docker.client.containerInfo(containerId);
const entry = (info.Config.Env || []).find(function(e) {
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
});
return entry ? entry.split('=')[1] : null;
} catch(err) {
return null;
}
}
async function getContainerPort(containerId) {
try {
const containers = await docker.client.listContainers({ all: true });
const c = containers.find(function(x) {
return x.Id === containerId || x.Id.startsWith(containerId);
});
if (c && c.Ports) {
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
if (p && p.PublicPort) return String(p.PublicPort);
}
return '18792';
} catch(err) {
return '18792';
}
}
async function gatewayHealth(baseUrl, token) {
return new Promise(function(resolve) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
let data = '';
res.on('data', function(d) { data += d; });
res.on('end', function() {
try { resolve({ ok: true, data: JSON.parse(data) }); }
catch(e) { resolve({ ok: true, data: data }); }
});
});
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
});
}
function proxyRequest(req, res, targetBase, path, token) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
headers['X-Forwarded-For'] = req.ip;
headers['X-Forwarded-Proto'] = req.protocol;
const url = targetBase + '/' + path;
const method = req.method;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const body = JSON.stringify(req.body);
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(body);
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
proxyReq.write(body);
proxyReq.end();
} else {
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
}
}
// ── GET /openclaw/status ────────────────────────────────────────────────
router.get('/status', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) {
return res.json({ success: true, deployed: false });
}
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const health = await gatewayHealth(baseUrl, token);
res.json({
success: true,
deployed: true,
container: {
id: container.Id.slice(0, 12),
name: container.Name,
state: container.State,
status: container.Status,
created: container.Created,
image: container.Image
},
gateway: {
url: baseUrl,
port: port,
healthy: health.ok,
healthData: health.data || null,
tokenSet: !!token
}
});
}));
// ── POST /openclaw/deploy ───────────────────────────────────────────────
router.post('/deploy', asyncHandler(async function(req, res) {
const existing = await findOpenClawContainer();
if (existing) {
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
}
const image = 'ghcr.io/nousresearch/openclaw:latest';
const name = 'openclaw-' + Date.now();
const gatewayToken = generateToken();
// Pull image
log.info('Pulling ' + image + '...');
try {
await new Promise(function(resolve, reject) {
docker.client.pull(image, function(err, stream) {
if (err) return reject(err);
docker.client.modem.followProgress(stream, function(err2) {
if (err2) return reject(err2);
resolve();
});
});
});
} catch(e) {
log.error('OpenClaw pull failed: ' + e.message);
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
}
// Create + start container
try {
const container = await docker.client.createContainer({
name: name,
Image: image,
Env: [
'OPENCLAW_GATEWAY_MODE=local',
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
],
HostConfig: {
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
RestartPolicy: { Name: 'unless-stopped' },
Labels: {
'dashcaddy.managed': 'true',
'dashcaddy.app': 'openclaw'
}
},
ExposedPorts: { '18792/tcp': {} }
});
await container.start();
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
res.json({
success: true,
deployed: true,
container: { id: container.id.slice(0, 12), name: name },
gateway: {
url: 'http://localhost:18792',
token: gatewayToken
}
});
} catch(e) {
log.error('OpenClaw deploy failed: ' + e.message);
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
}
}));
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
router.get('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
router.post('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── DELETE /openclaw ───────────────────────────────────────────────────
router.delete('/', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
try {
const c = docker.client.container(container.Id);
await c.stop().catch(function() {});
await c.remove({ force: true });
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
res.json({ success: true, message: 'OpenClaw removed' });
} catch(e) {
log.error('Failed to remove OpenClaw: ' + e.message);
res.status(500).json({ success: false, error: e.message });
}
}));
return router;
};
// ── token generator ──────────────────────────────────────────────────────────
function generateToken() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
+65
View File
@@ -0,0 +1,65 @@
const express = require('express');
/**
* Workflows routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.workflowEngine - WorkflowEngine instance
* @param {Object} deps.licenseManager - License manager for premium gating
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @returns {express.Router}
*/
module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
const router = express.Router();
// Apply premium gating to all workflows routes
router.use(licenseManager.requirePremium('workflows'));
// ===== WORKFLOW MANAGEMENT ENDPOINTS =====
// List all bundled workflows
router.get('/workflows', asyncHandler(async (req, res) => {
const workflows = workflowEngine.listWorkflows();
res.json({ success: true, workflows });
}, 'workflows-list'));
// Enable a workflow
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
res.json({ success: true, ...result });
}, 'workflows-enable'));
// Disable a workflow
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
res.json({ success: true, ...result });
}, 'workflows-disable'));
// Manually trigger a workflow
router.post('/workflows/:workflowId/run', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const triggerData = req.body || {};
triggerData.trigger = 'manual';
const result = await workflowEngine.executeWorkflow(workflowId, triggerData);
res.json({ success: true, result });
}, 'workflows-run'));
// Get execution history for a workflow
router.get('/workflows/:workflowId/history', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const limit = parseInt(req.query.limit) || 50;
const history = workflowEngine.getHistory(workflowId, limit);
res.json({ success: true, history });
}, 'workflows-history'));
// Get all workflow execution history
router.get('/workflows/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit);
res.json({ success: true, history });
}, 'workflows-all-history'));
return router;
};
+147 -98
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# DashCaddy Host-Side Updater # DashCaddy Host-Side Updater
# Triggered by systemd path unit when the container writes trigger.json. # Triggered by systemd path unit when the container writes trigger.json.
# Reads the trigger, backs up current API, copies new files, rebuilds container. # Reads the trigger, backs up current API + data/, copies new files, rebuilds container.
# Writes result.json so the new container knows the outcome. # Writes result.json so the new container knows the outcome.
# #
# This runs on the HOST, outside the container. # This runs on the HOST, outside the container.
@@ -16,6 +16,10 @@ readonly CONTAINER_NAME="dashcaddy-api"
readonly MAX_BACKUPS=3 readonly MAX_BACKUPS=3
readonly HEALTH_TIMEOUT=60 readonly HEALTH_TIMEOUT=60
# Data directory backup — stored alongside code backups so everything rolls back together
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
readonly DATA_BACKUP_PREFIX="data-backup"
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; } log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
write_result() { write_result() {
@@ -56,6 +60,34 @@ cleanup_old_backups() {
fi fi
} }
# ── Data backup (rsync for efficiency + permissions) ──────────────────────────
backup_data_dir() {
local backup_dir="$1"
if [[ -d "$DATA_SOURCE_DIR" ]]; then
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}"
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
else
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
fi
}
# ── Data restore ──────────────────────────────────────────────────────────────
restore_data_dir() {
local backup_dir="$1"
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
if [[ -d "$data_backup" ]]; then
log "Restoring data/ from backup..."
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
log "Data restored successfully"
else
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
fi
}
wait_for_health() { wait_for_health() {
local port="${1:-3001}" local port="${1:-3001}"
local timeout="$HEALTH_TIMEOUT" local timeout="$HEALTH_TIMEOUT"
@@ -75,6 +107,59 @@ wait_for_health() {
return 1 return 1
} }
# ── Shared rollback: restore code + data ────────────────────────────────────
rollback_restore() {
local backup_dir="$1"
log "Rolling back: restoring code files..."
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
done
if [[ -d "$backup_dir/routes" ]]; then
rm -rf "$api_source_dir/routes"
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
fi
if [[ -d "$backup_dir/src" ]]; then
rm -rf "$api_source_dir/src"
cp -rf "$backup_dir/src" "$api_source_dir/src"
fi
restore_data_dir "$backup_dir"
}
# ── Shared container restart (preserves SERVICES_FILE env var) ───────────────
# Uses rm + run so new env vars (e.g. SERVICES_FILE) take effect.
# If docker-compose is not configured, falls back to docker start.
restart_container() {
local image="$1"
log "Restarting container (rm + run to pick up env vars)..."
# Stop and remove existing container so new env var is applied
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
# Re-create with same volumes and the SERVICES_FILE env var
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
-p 127.0.0.1:3001:3001 \
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
-e SERVICES_FILE=/app/data/services.json \
"$image"
log "Container restarted with fresh env"
}
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
code_restore() {
local backup_dir="$1"
log "Restoring code files..."
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
done
if [[ -d "$backup_dir/routes" ]]; then
rm -rf "$api_source_dir/routes"
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
fi
if [[ -d "$backup_dir/src" ]]; then
rm -rf "$api_source_dir/src"
cp -rf "$backup_dir/src" "$api_source_dir/src"
fi
}
main() { main() {
local start_time local start_time
start_time=$(date +%s) start_time=$(date +%s)
@@ -94,45 +179,69 @@ main() {
staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])") staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])")
api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])") api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])")
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')") commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
# Frontend paths — optional (older self-updaters don't write these). When
# present, this script also syncs the dashboard files (Caddy serves them
# directly from the host; the container path /app/dashboard isn't mounted).
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')") frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')") frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
# Handle action=rollback (no new version to deploy)
local to_version="${version}"
log "=== ${action^^}: v${from_version} -> v${version} ===" log "=== ${action^^}: v${from_version} -> v${to_version} ==="
log "Staging: ${staging_dir}" log "Staging: ${staging_dir}"
log "API source: ${api_source_dir}" log "API source: ${api_source_dir}"
# Consume the trigger immediately so we don't re-process on failure # Consume the trigger immediately so we don't re-process on failure
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing" mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
# 2. Validate staging directory # ── Handle rollback ────────────────────────────────────────────────────────
if [[ "$action" == "rollback" ]]; then
local backup_dir="${BACKUPS_DIR}/${version}"
if [[ ! -d "$backup_dir" ]]; then
log "ERROR: No backup found for version ${version}"
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}"
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
log "Performing rollback to v${version}..."
rollback_restore "$backup_dir"
# Rebuild old code
log "Rebuilding container..."
cd "$api_source_dir"
docker build -t dashcaddy-dashcaddy-api:latest . 2>&1 | tail -1 || true
restart_container "dashcaddy-dashcaddy-api:latest"
wait_for_health || log "WARNING: Health check failed after rollback"
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
rm -f "${TRIGGER_FILE}.processing"
log "=== Rollback complete ==="
exit 0
fi
# ── Handle update ───────────────────────────────────────────────────────────
if [[ ! -d "$staging_dir" ]]; then if [[ ! -d "$staging_dir" ]]; then
log "ERROR: Staging directory not found: ${staging_dir}" log "ERROR: Staging directory not found: ${staging_dir}"
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "Staging directory not found" write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
rm -f "${TRIGGER_FILE}.processing" rm -f "${TRIGGER_FILE}.processing"
exit 1 exit 1
fi fi
# 3. Backup current API files # 2. Backup current API code + data/
local backup_dir="${BACKUPS_DIR}/${from_version}" local backup_dir="${BACKUPS_DIR}/${from_version}"
mkdir -p "$backup_dir" mkdir -p "$backup_dir"
log "Backing up current API files to ${backup_dir}" log "Backing up current API files to ${backup_dir}"
# Copy all JS files, package.json, Dockerfile, and tracked subdirs
for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true [[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
done done
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/" [[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/" [[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
# VERSION (commit hash) was copied from api_source_dir above; preserve as-is
# so a rollback restores the original commit marker. The version *string* is # Backup data/ directory (services.json, config.json, credentials, etc.)
# already encoded in the backup dir name (${from_version}). backup_data_dir "$backup_dir"
cleanup_old_backups cleanup_old_backups
# 4. Copy new files from staging to API source # 3. Copy new files from staging to API source
log "Deploying new API files..." log "Deploying new API files..."
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
@@ -145,19 +254,11 @@ main() {
rm -rf "$api_source_dir/src" rm -rf "$api_source_dir/src"
cp -rf "$staging_dir/src" "$api_source_dir/src" cp -rf "$staging_dir/src" "$api_source_dir/src"
fi fi
# Belt-and-suspenders: always write the commit from trigger.json to VERSION,
# even if the tarball didn't include one. The container's self-updater uses
# this to detect the "same version, different commit" case.
if [[ -n "$commit" ]]; then if [[ -n "$commit" ]]; then
echo "$commit" > "$api_source_dir/VERSION" echo "$commit" > "$api_source_dir/VERSION"
fi fi
# 4b. Sync frontend. Caddy serves the dashboard directly from the host # 3b. Sync frontend
# filesystem; the container-side copy in older self-updater.js builds wrote
# to /app/dashboard which isn't always mounted, so the real sync happens
# here. Trigger fields take precedence; if absent (older self-updater),
# fall back to: staging dir's sibling status/ + first existing known target.
if [[ -z "$frontend_staging_dir" ]]; then if [[ -z "$frontend_staging_dir" ]]; then
parent_staging=$(dirname "$staging_dir") parent_staging=$(dirname "$staging_dir")
[[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status" [[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status"
@@ -175,107 +276,55 @@ main() {
for sub in dist css vendor js; do for sub in dist css vendor js; do
if [[ -d "$frontend_staging_dir/$sub" ]]; then if [[ -d "$frontend_staging_dir/$sub" ]]; then
mkdir -p "$frontend_target_dir/$sub" mkdir -p "$frontend_target_dir/$sub"
cp -rf "$frontend_staging_dir/$sub"/* "$frontend_target_dir/$sub/" 2>/dev/null || true cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true
fi fi
done done
# assets/ is mounted into the container; usually already in sync via bind
# mount, but if a release ships new assets we want them on disk too.
if [[ -d "$frontend_staging_dir/assets" ]]; then if [[ -d "$frontend_staging_dir/assets" ]]; then
mkdir -p "$frontend_target_dir/assets" mkdir -p "$frontend_target_dir/assets"
cp -rf "$frontend_staging_dir/assets"/* "$frontend_target_dir/assets/" 2>/dev/null || true cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
fi fi
fi fi
# 5. Rebuild container # 4. Rebuild container
log "Rebuilding container..." log "Rebuilding container..."
cd "$api_source_dir" cd "$api_source_dir"
local build_ok=false local build_ok=false
if docker compose build --quiet 2>&1; then local image_tag="dashcaddy-dashcaddy-api:latest"
build_ok=true
elif docker-compose build --quiet 2>&1; then if docker build -t "$image_tag" . 2>&1; then
build_ok=true build_ok=true
fi fi
if [[ "$build_ok" != "true" ]]; then if [[ "$build_ok" != "true" ]]; then
log "ERROR: Docker build failed — rolling back" log "ERROR: Docker build failed — rolling back code + data"
code_restore "$backup_dir"
# Restore backup docker build -t "$image_tag" . 2>&1 | tail -3 || true
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do restart_container "$image_tag"
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true wait_for_health || true
done write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
if [[ -d "$backup_dir/routes" ]]; then
rm -rf "$api_source_dir/routes"
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
fi
if [[ -d "$backup_dir/src" ]]; then
rm -rf "$api_source_dir/src"
cp -rf "$backup_dir/src" "$api_source_dir/src"
fi
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "Docker build failed"
rm -f "${TRIGGER_FILE}.processing" rm -f "${TRIGGER_FILE}.processing"
exit 1 exit 1
fi fi
# 6. Restart container # 5. Restart container (rm + run so new env vars take effect)
log "Restarting container..." restart_container "$image_tag"
if docker compose up -d 2>&1 || docker-compose up -d 2>&1; then
log "Container restarted"
else
log "ERROR: Container restart failed — rolling back"
# Restore backup # 6. Health check
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
done
if [[ -d "$backup_dir/routes" ]]; then
rm -rf "$api_source_dir/routes"
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
fi
if [[ -d "$backup_dir/src" ]]; then
rm -rf "$api_source_dir/src"
cp -rf "$backup_dir/src" "$api_source_dir/src"
fi
docker compose build --quiet 2>&1 || docker-compose build --quiet 2>&1 || true
docker compose up -d 2>&1 || docker-compose up -d 2>&1 || true
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "Container restart failed"
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
# 7. Health check
if wait_for_health; then if wait_for_health; then
local duration=$(( $(date +%s) - start_time )) local duration=$(( $(date +%s) - start_time ))
log "=== Update successful: v${version} in ${duration}s ===" log "=== Update successful: v${to_version} in ${duration}s ==="
write_result "true" "$version" "$duration" write_result "true" "$to_version" "$duration"
else else
local duration=$(( $(date +%s) - start_time )) local duration=$(( $(date +%s) - start_time ))
log "ERROR: Health check failed after update — rolling back" log "ERROR: Health check failed after update — rolling back code + data"
rollback_restore "$backup_dir"
# Restore backup docker build -t "$image_tag" . 2>&1 | tail -3 || true
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do restart_container "$image_tag"
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
done
if [[ -d "$backup_dir/routes" ]]; then
rm -rf "$api_source_dir/routes"
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
fi
if [[ -d "$backup_dir/src" ]]; then
rm -rf "$api_source_dir/src"
cp -rf "$backup_dir/src" "$api_source_dir/src"
fi
docker compose build --quiet 2>&1 || docker-compose build --quiet 2>&1 || true
docker compose up -d 2>&1 || docker-compose up -d 2>&1 || true
wait_for_health || log "WARNING: Rollback health check also failed" wait_for_health || log "WARNING: Rollback health check also failed"
write_result "false" "$to_version" "$duration" "Health check failed after update"
write_result "false" "$version" "$duration" "Health check failed after update"
fi fi
# 8. Cleanup # 7. Cleanup
rm -f "${TRIGGER_FILE}.processing" rm -f "${TRIGGER_FILE}.processing"
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
+36 -1
View File
@@ -67,9 +67,35 @@ process.on('uncaughtException', (error) => {
const portLockManager = require('./port-lock-manager'); const portLockManager = require('./port-lock-manager');
// Optional modules // Optional modules
let dockerMaintenance, logDigest; let dockerMaintenance, logDigest, bundledWorkflows;
try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ } try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ }
try { logDigest = require('./log-digest'); } catch { /* optional */ } try { logDigest = require('./log-digest'); } catch { /* optional */ }
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
// Initialize workflow engine if bundled-workflows is available
let workflowEngine = null;
if (bundledWorkflows) {
try {
const { WorkflowEngine } = bundledWorkflows;
// Create a context with needed services
const workflowCtx = {
docker: { client: require('dockerode')() },
notification: require('./notification-manager')({
NOTIFICATIONS_FILE: process.env.NOTIFICATIONS_FILE || require('./platform-paths').notificationsFile,
fetchT,
log,
config
}),
backupManager,
resourceMonitor,
servicesStateManager
};
workflowEngine = new WorkflowEngine(workflowCtx);
log.info('server', 'Workflow engine initialized');
} catch (err) {
log.error('server', 'Workflow engine failed to initialize', { error: err.message });
}
}
log.info('server', 'Starting feature modules'); log.info('server', 'Starting feature modules');
@@ -81,6 +107,10 @@ process.on('uncaughtException', (error) => {
// Resource monitoring // Resource monitoring
try { try {
resourceMonitor.start(); resourceMonitor.start();
// Connect workflow engine to resource monitor for resource-alert events
if (workflowEngine) {
resourceMonitor.setWorkflowEngine(workflowEngine);
}
log.info('server', 'Resource monitoring started'); log.info('server', 'Resource monitoring started');
} catch (err) { } catch (err) {
log.error('server', 'Resource monitoring failed to start', { error: err.message }); log.error('server', 'Resource monitoring failed to start', { error: err.message });
@@ -94,6 +124,11 @@ process.on('uncaughtException', (error) => {
log.error('server', 'Backup manager failed to start', { error: err.message }); log.error('server', 'Backup manager failed to start', { error: err.message });
} }
// Connect workflow engine to update manager for pre-update events
if (workflowEngine) {
updateManager.setWorkflowEngine(workflowEngine);
}
// Health checker (with service sync) // Health checker (with service sync)
(async () => { (async () => {
try { try {
+2 -1
View File
@@ -404,7 +404,8 @@ async function createApp() {
})); }));
apiRouter.use(backupsRoutes({ apiRouter.use(backupsRoutes({
backupManager: ctx.backupManager, backupManager: ctx.backupManager,
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({
+9 -5
View File
@@ -6,6 +6,7 @@ const { createDockerContext } = require('./docker');
const { createCaddyContext } = require('./caddy'); const { createCaddyContext } = require('./caddy');
const { createDnsContext } = require('./dns'); const { createDnsContext } = require('./dns');
const { createSessionContext } = require('./session'); const { createSessionContext } = require('./session');
const NotificationManager = require('../../notification-manager');
/** /**
* Assemble the full application context * Assemble the full application context
@@ -85,11 +86,14 @@ function assembleContext({
const dns = createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE); const dns = createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
const session = createSessionContext(middlewareResult); const session = createSessionContext(middlewareResult);
// Notification context (inline for now - could be extracted) // Create notification manager
const notification = { const notification = new NotificationManager({
// These will be populated by server.js for now NOTIFICATIONS_FILE,
// TODO: Extract notification module fetchT,
}; docker,
log,
config: siteConfig
});
// Tailscale context (inline for now - could be extracted) // Tailscale context (inline for now - could be extracted)
const tailscale = { const tailscale = {
@@ -0,0 +1,335 @@
// ========== MONITORING WIDGETS ==========
// Embeds a compact system-resource + health summary panel directly on the
// main dashboard. Replaces the need for a separate monitoring-dashboard.html
// page — quick at-a-glance stats where you already are.
(function () {
// ----- Style injection (scoped to .dc-monitor so it doesn't leak) -----
const styleEl = document.createElement('style');
styleEl.textContent = `
.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(styleEl);
// ----- Container element (inserted above service-filter-bar) -----
const filterBar = document.getElementById('service-filter-bar');
if (!filterBar) return;
const panel = document.createElement('div');
panel.className = 'dc-monitor';
panel.id = 'dc-monitor-panel';
panel.innerHTML = `
<div class="dc-monitor-header" style="grid-column: 1 / -1;">
<div class="dc-monitor-title">📊 System Overview</div>
<span class="dc-monitor-refresh" id="dc-monitor-refresh-stamp"></span>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Services</div>
<div class="dc-monitor-value" id="dc-monitor-services"></div>
<div class="dc-monitor-sub" id="dc-monitor-services-sub">loading</div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Containers Up</div>
<div class="dc-monitor-value" id="dc-monitor-containers"></div>
<div class="dc-monitor-sub" id="dc-monitor-containers-sub">loading</div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Avg CPU</div>
<div class="dc-monitor-value" id="dc-monitor-cpu"></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"></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"></div>
<div class="dc-monitor-sub" id="dc-monitor-health-sub"></div>
</div>
`;
// Insert ABOVE the filter bar
filterBar.parentNode.insertBefore(panel, filterBar);
// ----- Helpers -----
function setBar(id, pct) {
const el = document.getElementById(id);
if (!el) return;
const p = Math.max(0, Math.min(100, Number(pct) || 0));
el.style.width = p + '%';
el.classList.remove('warn', 'bad');
if (p >= 85) el.classList.add('bad');
else if (p >= 65) el.classList.add('warn');
}
function fmtPct(v) {
if (v == null || isNaN(v)) return '—';
return (Math.round(v * 10) / 10) + '%';
}
function fmtBytes(b) {
if (b == null || isNaN(b)) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
return b.toFixed(1) + ' ' + units[i];
}
// ----- Robust services count -----
// Read from multiple sources so we always have a number:
// 1. window.APPS (populated by grid.js after loadServices)
// 2. #cards .card elements (post-buildGrid)
// 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 sub = document.getElementById('dc-monitor-services-sub');
if (el) el.textContent = `${up} / ${total}`;
if (sub) sub.textContent = total === 0
? 'no services yet'
: `${up} online · ${total - up} offline`;
}
function applyHealthSummary(data) {
const el = document.getElementById('dc-monitor-health');
const sub = document.getElementById('dc-monitor-health-sub');
if (!el) return;
if (!data || data.summary == null) {
el.textContent = '—';
if (sub) sub.textContent = 'no data';
return;
}
const s = data.summary;
const healthy = s.healthy ?? s.up ?? 0;
const unhealthy = s.unhealthy ?? s.down ?? 0;
const total = s.total ?? (healthy + unhealthy);
el.textContent = `${healthy}/${total}`;
if (sub) {
if (unhealthy === 0) {
sub.innerHTML = '<span class="dc-monitor-pill ok">● all healthy</span>';
} else if (unhealthy <= 2) {
sub.innerHTML = `<span class="dc-monitor-pill warn">● ${unhealthy} degraded</span>`;
} else {
sub.innerHTML = `<span class="dc-monitor-pill bad">● ${unhealthy} down</span>`;
}
}
}
// ----- Data fetches -----
async function fetchStats() {
try {
const r = await fetch('/api/v1/monitoring/stats', { cache: 'no-store' });
if (!r.ok) return null;
const data = await r.json();
return (data && data.stats) ? data.stats : null;
} catch (_) {
return null;
}
}
async function fetchHealth() {
try {
const r = await fetch('/api/v1/health-checks/status', { cache: 'no-store' });
if (!r.ok) return null;
return await r.json();
} catch (_) {
return null;
}
}
function applyStats(stats) {
const containers = document.getElementById('dc-monitor-containers');
const containersSub = document.getElementById('dc-monitor-containers-sub');
const cpuEl = document.getElementById('dc-monitor-cpu');
const memEl = document.getElementById('dc-monitor-mem');
if (!stats) {
if (containers) containers.textContent = '—';
if (cpuEl) cpuEl.textContent = '—';
if (memEl) memEl.textContent = '—';
return;
}
const entries = Object.values(stats);
if (entries.length === 0) {
if (containers) containers.textContent = '0';
if (containersSub) containersSub.textContent = 'no containers reporting';
if (cpuEl) cpuEl.textContent = '0%';
if (memEl) memEl.textContent = '0%';
setBar('dc-monitor-cpu-bar', 0);
setBar('dc-monitor-mem-bar', 0);
return;
}
let cpuSum = 0, memSum = 0, memBytes = 0, cpuCount = 0, memCount = 0;
entries.forEach(s => {
// CPU may be percentage (0-100) or fraction (0-1) — handle both
if (s.cpu != null) {
const cpu = Number(s.cpu);
if (!isNaN(cpu)) {
cpuSum += cpu > 1 ? cpu : cpu * 100;
cpuCount++;
}
}
if (s.memory != null) {
const mem = Number(s.memory);
if (!isNaN(mem)) {
memSum += mem;
memBytes += Number(s.memoryUsage || 0);
memCount++;
}
}
});
const avgCpu = cpuCount ? cpuSum / cpuCount : 0;
const avgMem = memCount ? memSum / memCount : 0;
if (containers) containers.textContent = String(entries.length);
if (containersSub) {
const memTxt = memBytes ? ` · ${fmtBytes(memBytes)} RAM` : '';
containersSub.textContent = `running${memTxt}`;
}
if (cpuEl) cpuEl.textContent = fmtPct(avgCpu);
if (memEl) memEl.textContent = fmtPct(avgMem);
setBar('dc-monitor-cpu-bar', avgCpu);
setBar('dc-monitor-mem-bar', avgMem);
}
// ----- Public refresh function -----
let inFlight = false;
async function refresh() {
if (inFlight) return;
inFlight = true;
try {
setServicesCard();
const [stats, health] = await Promise.all([fetchStats(), fetchHealth()]);
applyStats(stats);
applyHealthSummary(health);
const stamp = document.getElementById('dc-monitor-refresh-stamp');
if (stamp) {
const now = new Date();
stamp.textContent = `updated ${now.toLocaleTimeString()}`;
}
} finally {
inFlight = false;
}
}
// Expose for init.js to call once and re-call after each refreshAll cycle
window.refreshMonitoringWidgets = refresh;
// Auto-refresh on the STATS interval (separate from full DASHBOARD refresh)
setInterval(refresh, (typeof DC !== 'undefined' && DC.POLL && DC.POLL.STATS) || 5000);
// Refresh once on first script load (init.js also calls this; double-call is harmless)
setTimeout(refresh, 200);
})();
+38
View File
@@ -64,6 +64,38 @@ class UpdateManager extends EventEmitter {
} }
} }
/**
* Trigger bundled workflows for an event
*/
triggerWorkflows(eventType, eventData) {
if (!this.workflowEngine) {
console.log('[UpdateManager] Workflow engine not set, skipping workflow trigger');
return;
}
try {
this.workflowEngine.triggerForEvent(eventType, eventData)
.then(results => {
if (results && results.length > 0) {
console.log(`[UpdateManager] Triggered ${results.length} workflow(s) for ${eventType}`);
}
})
.catch(err => {
console.error('[UpdateManager] Workflow trigger error:', err.message);
});
} catch (error) {
console.error('[UpdateManager] Error triggering workflows:', error.message);
}
}
/**
* Set the workflow engine for triggering workflows
*/
setWorkflowEngine(workflowEngine) {
this.workflowEngine = workflowEngine;
console.log('[UpdateManager] Workflow engine configured');
}
/** /**
* Check for updates for all containers * Check for updates for all containers
*/ */
@@ -281,6 +313,12 @@ class UpdateManager extends EventEmitter {
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
// Emit pre-update event for bundled workflows (e.g., backup-before-update)
this.emit('pre-update', { containerId, containerName, imageName, backup });
// Also trigger workflows for pre-update event directly
this.triggerWorkflows('pre-update', { containerId, containerName, appId: containerName, imageName });
// Pull latest image // Pull latest image
console.log(`[UpdateManager] Pulling latest image: ${imageName}`); console.log(`[UpdateManager] Pulling latest image: ${imageName}`);
await this.pullImage(imageName); await this.pullImage(imageName);
+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
+455 -333
View File
@@ -94,7 +94,9 @@
<!-- 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-automated">Automated</button> <button class="panel-tab" data-panel="backup-schedules-tab">Schedules</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>
@@ -143,12 +145,32 @@
<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: Automated Backups --> <!-- Tab: Schedules (Premium) -->
<div id="backup-automated" class="panel-section"> <div id="backup-schedules-tab" class="panel-section">
<div id="backup-schedule-container"> <div id="backup-schedules-container">
<div class="panel-empty"> <div class="panel-empty">
<span class="empty-icon"></span> <span class="empty-icon"></span>
<span class="brand-spinner"></span> Loading backup schedule... <span class="brand-spinner"></span> Loading schedules...
</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">💾</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"></span>
<span class="brand-spinner"></span> Loading...
</div> </div>
</div> </div>
</div> </div>
@@ -181,8 +203,10 @@
var previewContent = document.getElementById('backup-preview-content'); var previewContent = document.getElementById('backup-preview-content');
var restoreBtn = document.getElementById('backup-do-restore-btn'); var restoreBtn = document.getElementById('backup-do-restore-btn');
var resultDiv = document.getElementById('backup-result'); var resultDiv = document.getElementById('backup-result');
var scheduleContainer = document.getElementById('backup-schedule-container'); var scheduleContainer = document.getElementById('backup-schedules-container');
var historyContainer = document.getElementById('backup-history-container'); var historyContainer = document.getElementById('backup-history-container');
var diskContainer = document.getElementById('backup-disk-container');
var pointintimeContainer = document.getElementById('pointintime-container');
var selectedBackup = null; var selectedBackup = null;
@@ -383,359 +407,272 @@
restoreBtn.innerHTML = '⚡ Restore Everything'; restoreBtn.innerHTML = '⚡ Restore Everything';
}); });
// === Automated Backups Tab === // === Schedules Tab (Premium) ===
// Holds the destination currently being edited in the form async function loadSchedulesTab() {
var currentDestination = { type: 'local' };
async function loadBackupSchedule() {
if (!scheduleContainer) return; if (!scheduleContainer) return;
scheduleContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
try { try {
var res = await fetch('/api/v1/backups/config'); var res = await fetch('/api/v1/backups/schedule');
var data = await res.json(); var data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to load config');
var cfg = data.config?.backups || {}; if (data.premiumRequired) {
var autoKey = Object.keys(cfg)[0]; scheduleContainer.innerHTML = '<div class="panel-empty" style="padding: 24px; text-align: center;">' +
var auto = autoKey ? cfg[autoKey] : null; '<div style="font-size: 2rem; margin-bottom: 12px;">⭐</div>' +
'<div style="font-weight: 600; margin-bottom: 8px;">Premium Feature</div>' +
// Pull existing destination (first one) — fall back to local '<div style="font-size: 0.85rem; color: var(--muted);">Auto-backup scheduling requires a DashCaddy Premium subscription.</div>' +
var existingDest = (auto?.destinations && auto.destinations[0]) || { type: 'local' }; '<button onclick="showNotification(\'Upgrade to Premium to enable auto-backups!\', \'info\'); scrollToSection(\'license\');" style="margin-top: 16px; padding: 8px 20px; background: linear-gradient(135deg, #f39c12, #e67e22); border: none; color: white; border-radius: 8px; cursor: pointer; font-weight: 600;">Upgrade to Premium</button>' +
currentDestination = JSON.parse(JSON.stringify(existingDest)); '</div>';
return;
var html = '<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); margin-bottom: 16px;">'; }
html += '<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">⏰ Backup Schedule</h4>';
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">'; if (!data.success) throw new Error(data.error || 'Failed to load schedules');
html += '<div><label style="font-size: 0.8rem; color: var(--muted);">Schedule:</label>';
html += ' <select id="backup-schedule-select" style="width: 100%;">'; var schedules = data.schedules || [];
html += ' <option value="disabled"' + (!auto?.enabled ? ' selected' : '') + '>Disabled</option>';
html += ' <option value="hourly"' + (auto?.schedule === 'hourly' ? ' selected' : '') + '>Hourly</option>'; if (schedules.length === 0) {
html += ' <option value="daily"' + (auto?.schedule === 'daily' ? ' selected' : '') + '>Daily</option>'; scheduleContainer.innerHTML = '<div class="panel-empty">' +
html += ' <option value="weekly"' + (auto?.schedule === 'weekly' ? ' selected' : '') + '>Weekly</option>'; '<span class="empty-icon">⏰</span>' +
html += ' <option value="monthly"' + (auto?.schedule === 'monthly' ? ' selected' : '') + '>Monthly</option>'; '<div>No backup schedules configured</div>' +
html += ' </select></div>'; '<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Select apps below to enable auto-backup</div>' +
html += '<div><label style="font-size: 0.8rem; color: var(--muted);">Keep last:</label>'; '</div>';
html += ' <select id="backup-retention-select" style="width: 100%;">'; return;
html += ' <option value="3"' + (auto?.retention?.keep === 3 ? ' selected' : '') + '>3 backups</option>'; }
html += ' <option value="5"' + (!auto?.retention || auto?.retention?.keep === 5 ? ' selected' : '') + '>5 backups</option>';
html += ' <option value="10"' + (auto?.retention?.keep === 10 ? ' selected' : '') + '>10 backups</option>'; var html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
html += ' <option value="30"' + (auto?.retention?.keep === 30 ? ' selected' : '') + '>30 backups</option>'; for (var i = 0; i < schedules.length; i++) {
html += ' </select></div>'; var sch = schedules[i];
var nextRunStr = sch.nextRun ? new Date(sch.nextRun).toLocaleString() : 'Not scheduled';
var lastRunStr = sch.lastRun ? new Date(sch.lastRun).toLocaleString() : 'Never';
html += '<div style="padding: 12px 14px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">' +
' <div style="font-weight: 600; font-size: 0.9rem;">' + escapeHtml(sch.appId) + '</div>' +
' <label class="toggle-switch" style="display: flex; align-items: center; gap: 6px;">' +
' <input type="checkbox" class="schedule-toggle" data-appid="' + escapeHtml(sch.appId) + '"' + (sch.enabled ? ' checked' : '') + ' />' +
' <span style="font-size: 0.75rem; color: var(--muted);">' + (sch.enabled ? 'ON' : 'OFF') + '</span>' +
' </label>' +
'</div>' +
'<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.8rem; margin-bottom: 10px;">' +
' <div><span style="color: var(--muted);">Schedule:</span> <select class="schedule-select" data-appid="' + escapeHtml(sch.appId) + '" style="background: var(--base); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px;">' +
' <option value="hourly"' + (sch.schedule === 'hourly' ? ' selected' : '') + '>Hourly</option>' +
' <option value="daily"' + (sch.schedule === 'daily' ? ' selected' : '') + '>Daily</option>' +
' <option value="weekly"' + (sch.schedule === 'weekly' ? ' selected' : '') + '>Weekly</option>' +
' <option value="monthly"' + (sch.schedule === 'monthly' ? ' selected' : '') + '>Monthly</option>' +
' </select></div>' +
' <div><span style="color: var(--muted);">Keep last:</span> <input type="number" class="retention-input" data-appid="' + escapeHtml(sch.appId) + '" value="' + (sch.retention?.keep || 7) + '" min="1" max="100" style="width: 50px; background: var(--base); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px;" /></div>' +
'</div>' +
'<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 10px;">' +
' <div>Next run: ' + escapeHtml(nextRunStr) + '</div>' +
' <div>Last run: ' + escapeHtml(lastRunStr) + '</div>' +
'</div>' +
'<div style="display: flex; gap: 6px;">' +
' <button class="schedule-run-now" data-appid="' + escapeHtml(sch.appId) + '" style="padding: 5px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--ok-fg) 20%, transparent); border: 1px solid var(--ok-fg); color: var(--ok-fg); border-radius: 6px; cursor: pointer;">▶️ Run Now</button>' +
' <button class="schedule-delete" data-appid="' + escapeHtml(sch.appId) + '" style="padding: 5px 12px; font-size: 0.8rem; background: transparent; border: 1px solid var(--bad-fg); color: var(--bad-fg); border-radius: 6px; cursor: pointer;">🗑️ Remove</button>' +
'</div>' +
'</div>';
}
html += '</div>'; html += '</div>';
html += '<div style="margin-top: 12px;">';
html += ' <label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;">'; // Add "Add Schedule" section at bottom
html += ' <input type="checkbox" id="backup-encrypt-toggle"' + (auto?.encrypt !== false ? ' checked' : '') + ' />'; html += '<div style="margin-top: 16px; padding: 16px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 10px; border: 1px dashed var(--border);">' +
html += ' Encrypt backups'; '<h4 style="margin: 0 0 12px; font-size: 0.85rem; color: var(--muted);"> Add New Schedule</h4>' +
html += ' </label></div>'; '<div style="display: flex; gap: 8px; flex-wrap: wrap;">' +
html += '<div style="display: flex; gap: 8px; margin-top: 12px;">'; ' <input type="text" id="new-schedule-appid" placeholder="App ID (e.g., plex, sonarr)" style="flex: 1; min-width: 150px; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);" />' +
html += ' <button id="backup-save-schedule" style="padding: 8px 16px; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 6px; cursor: pointer; font-weight: 500;">Save Schedule</button>'; ' <select id="new-schedule-interval" style="padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);">' +
html += ' <button id="backup-run-now" style="padding: 8px 16px; border-radius: 6px; cursor: pointer;">▶️ Run Backup Now</button>'; ' <option value="hourly">Hourly</option>' +
html += '</div>'; ' <option value="daily" selected>Daily</option>' +
html += '</div>'; ' <option value="weekly">Weekly</option>' +
' <option value="monthly">Monthly</option>' +
// === Destination Section === ' <option value="6h">Every 6 hours</option>' +
html += '<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); margin-bottom: 16px;">'; ' <option value="30m">Every 30 minutes</option>' +
html += '<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">☁️ Backup Destination</h4>'; ' </select>' +
html += '<div><label style="font-size: 0.8rem; color: var(--muted);">Where to store backups:</label>'; ' <input type="number" id="new-schedule-retention" value="7" min="1" max="100" placeholder="Keep" style="width: 70px; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base);" />' +
html += ' <select id="backup-dest-type" style="width: 100%;">'; ' <button id="add-schedule-btn" style="padding: 8px 16px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Add Schedule</button>' +
html += ' <option value="local"' + (currentDestination.type === 'local' ? ' selected' : '') + '>💾 Local disk</option>'; '</div>' +
html += ' <option value="dropbox"' + (currentDestination.type === 'dropbox' ? ' selected' : '') + '>📦 Dropbox</option>'; '</div>';
html += ' <option value="webdav"' + (currentDestination.type === 'webdav' ? ' selected' : '') + '>🌐 WebDAV (Nextcloud / ownCloud)</option>';
html += ' <option value="sftp"' + (currentDestination.type === 'sftp' ? ' selected' : '') + '>🔐 SFTP</option>';
html += ' </select></div>';
html += '<div id="backup-dest-form" style="margin-top: 12px;"></div>';
html += '<div id="backup-dest-result" style="display: none; margin-top: 10px; padding: 8px 10px; border-radius: 6px; font-size: 0.8rem;"></div>';
html += '</div>';
html += '<div id="backup-schedule-result" style="display: none; margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 0.85rem;"></div>';
scheduleContainer.innerHTML = html; scheduleContainer.innerHTML = html;
document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule); // Wire up event listeners
document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow); scheduleContainer.querySelectorAll('.schedule-toggle').forEach(function(toggle) {
toggle.addEventListener('change', function() { updateSchedule(toggle.dataset.appid, { enabled: toggle.checked }); });
var destTypeSel = document.getElementById('backup-dest-type');
destTypeSel?.addEventListener('change', function() {
currentDestination = { type: destTypeSel.value };
renderDestinationForm(destTypeSel.value);
}); });
renderDestinationForm(currentDestination.type); scheduleContainer.querySelectorAll('.schedule-select').forEach(function(sel) {
sel.addEventListener('change', function() { updateSchedule(sel.dataset.appid, { schedule: sel.value }); });
});
scheduleContainer.querySelectorAll('.retention-input').forEach(function(inp) {
inp.addEventListener('change', function() { updateSchedule(inp.dataset.appid, { retention: { keep: parseInt(inp.value) || 7 } }); });
});
scheduleContainer.querySelectorAll('.schedule-run-now').forEach(function(btn) {
btn.addEventListener('click', function() { runBackupNow(btn.dataset.appid); });
});
scheduleContainer.querySelectorAll('.schedule-delete').forEach(function(btn) {
btn.addEventListener('click', function() { deleteSchedule(btn.dataset.appid); });
});
document.getElementById('add-schedule-btn')?.addEventListener('click', addNewSchedule);
} catch (e) { } catch (e) {
scheduleContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: ' + escapeHtml(e.message) + '</div>'; scheduleContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed to load: ' + escapeHtml(e.message) + '</div>';
} }
} }
// Render the provider-specific form fields and load saved credentials (masked) async function updateSchedule(appId, updates) {
async function renderDestinationForm(type) { try {
var formEl = document.getElementById('backup-dest-form'); var res = await secureFetch('/api/v1/backups/schedule', {
if (!formEl) return; method: 'POST',
headers: { 'Content-Type': 'application/json' },
if (type === 'local') { body: JSON.stringify({ appId, ...updates })
formEl.innerHTML = '<div style="font-size: 0.8rem; color: var(--muted); padding: 8px;">Backups are stored on the host filesystem. No additional configuration required.</div>'; });
var data = await res.json();
if (data.success) {
showNotification('Schedule updated for ' + appId, 'success');
} else {
showNotification('Update failed: ' + (data.error || 'Unknown'), 'error');
loadSchedulesTab(); // Refresh on failure
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
}
}
async function runBackupNow(appId) {
try {
var res = await secureFetch('/api/v1/backups/backup/' + encodeURIComponent(appId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
var data = await res.json();
if (data.success) {
showNotification('Backup started for ' + appId + '!', 'success');
} else {
showNotification('Backup failed: ' + (data.error || 'Unknown'), 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
}
}
async function deleteSchedule(appId) {
if (!confirm('Remove backup schedule for ' + appId + '?')) return;
try {
var res = await secureFetch('/api/v1/backups/schedule/' + encodeURIComponent(appId), { method: 'DELETE' });
var data = await res.json();
if (data.success) {
showNotification('Schedule removed for ' + appId, 'success');
loadSchedulesTab();
} else {
showNotification('Delete failed: ' + (data.error || 'Unknown'), 'error');
}
} catch (e) {
showNotification('Error: ' + e.message, 'error');
}
}
async function addNewSchedule() {
var appId = document.getElementById('new-schedule-appid')?.value?.trim();
var interval = document.getElementById('new-schedule-interval')?.value || 'daily';
var retention = parseInt(document.getElementById('new-schedule-retention')?.value) || 7;
if (!appId) {
showNotification('Please enter an App ID', 'warning');
return; return;
} }
var html = '';
if (type === 'dropbox') {
html += '<label style="font-size: 0.8rem; color: var(--muted);">Access Token:</label>';
html += '<input type="password" id="dest-dropbox-token" placeholder="sl.B..." style="width: 100%; margin-bottom: 8px;" />';
html += '<label style="font-size: 0.8rem; color: var(--muted);">Folder path:</label>';
html += '<input type="text" id="dest-dropbox-path" placeholder="/dashcaddy-backups" value="' + escapeHtml(currentDestination.path || '/dashcaddy-backups') + '" style="width: 100%; margin-bottom: 8px;" />';
html += '<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 8px;">Generate a token at <a href="https://www.dropbox.com/developers/apps" target="_blank" style="color: var(--accent);">Dropbox App Console</a> with files.content.write + files.content.read scopes.</div>';
} else if (type === 'webdav') {
html += '<label style="font-size: 0.8rem; color: var(--muted);">Server URL:</label>';
html += '<input type="text" id="dest-webdav-url" placeholder="https://cloud.example.com/remote.php/dav/files/username" style="width: 100%; margin-bottom: 8px;" />';
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">';
html += ' <div><label style="font-size: 0.8rem; color: var(--muted);">Username:</label>';
html += ' <input type="text" id="dest-webdav-username" style="width: 100%;" /></div>';
html += ' <div><label style="font-size: 0.8rem; color: var(--muted);">Password / App password:</label>';
html += ' <input type="password" id="dest-webdav-password" style="width: 100%;" /></div>';
html += '</div>';
html += '<label style="font-size: 0.8rem; color: var(--muted);">Folder path:</label>';
html += '<input type="text" id="dest-webdav-path" placeholder="/dashcaddy-backups" value="' + escapeHtml(currentDestination.path || '/dashcaddy-backups') + '" style="width: 100%; margin-bottom: 8px;" />';
} else if (type === 'sftp') {
html += '<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">';
html += ' <div><label style="font-size: 0.8rem; color: var(--muted);">Host:</label>';
html += ' <input type="text" id="dest-sftp-host" placeholder="backup.example.com" style="width: 100%;" /></div>';
html += ' <div><label style="font-size: 0.8rem; color: var(--muted);">Port:</label>';
html += ' <input type="number" id="dest-sftp-port" value="22" style="width: 100%;" /></div>';
html += '</div>';
html += '<label style="font-size: 0.8rem; color: var(--muted);">Username:</label>';
html += '<input type="text" id="dest-sftp-username" style="width: 100%; margin-bottom: 8px;" />';
html += '<label style="font-size: 0.8rem; color: var(--muted);">Auth method:</label>';
html += '<select id="dest-sftp-authtype" style="width: 100%; margin-bottom: 8px;">';
html += ' <option value="password">Password</option>';
html += ' <option value="key">Private key</option>';
html += '</select>';
html += '<div id="dest-sftp-password-row"><label style="font-size: 0.8rem; color: var(--muted);">Password:</label>';
html += ' <input type="password" id="dest-sftp-password" style="width: 100%; margin-bottom: 8px;" /></div>';
html += '<div id="dest-sftp-key-row" style="display: none;"><label style="font-size: 0.8rem; color: var(--muted);">Private key (PEM):</label>';
html += ' <textarea id="dest-sftp-privatekey" rows="4" style="width: 100%; font-family: monospace; font-size: 0.75rem; margin-bottom: 8px;" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea></div>';
html += '<label style="font-size: 0.8rem; color: var(--muted);">Remote path:</label>';
html += '<input type="text" id="dest-sftp-path" placeholder="/home/user/dashcaddy-backups" value="' + escapeHtml(currentDestination.path || '/home/user/dashcaddy-backups') + '" style="width: 100%; margin-bottom: 8px;" />';
}
html += '<div style="display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap;">';
html += ' <button id="dest-save-creds" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; cursor: pointer;">💾 Save Credentials</button>';
html += ' <button id="dest-test-conn" style="padding: 6px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 6px; cursor: pointer;">🔌 Test Connection</button>';
html += ' <button id="dest-clear-creds" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; cursor: pointer; color: var(--bad-fg);">🗑️ Clear</button>';
html += '</div>';
formEl.innerHTML = html;
// SFTP auth type toggle
if (type === 'sftp') {
var authSel = document.getElementById('dest-sftp-authtype');
var pwRow = document.getElementById('dest-sftp-password-row');
var keyRow = document.getElementById('dest-sftp-key-row');
authSel?.addEventListener('change', function() {
if (authSel.value === 'key') { pwRow.style.display = 'none'; keyRow.style.display = ''; }
else { pwRow.style.display = ''; keyRow.style.display = 'none'; }
});
}
document.getElementById('dest-save-creds')?.addEventListener('click', function() { saveCredentials(type); });
document.getElementById('dest-test-conn')?.addEventListener('click', function() { testDestination(type); });
document.getElementById('dest-clear-creds')?.addEventListener('click', function() { clearCredentials(type); });
// Pull existing (masked) credentials
await loadCredentials(type);
}
function destResult(msg, ok) {
var el = document.getElementById('backup-dest-result');
if (!el) return;
el.innerHTML = msg;
el.style.display = 'block';
el.style.background = ok ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
el.style.border = ok ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)';
}
async function loadCredentials(provider) {
try { try {
var res = await fetch('/api/v1/backups/credentials/' + provider); var res = await secureFetch('/api/v1/backups/schedule', {
var data = await res.json();
if (!data.success || !data.credentials) return;
var c = data.credentials;
if (provider === 'dropbox') {
var t = document.getElementById('dest-dropbox-token'); if (t && c.token) t.value = c.token;
} else if (provider === 'webdav') {
var u = document.getElementById('dest-webdav-url'); if (u && c.url) u.value = c.url;
var n = document.getElementById('dest-webdav-username'); if (n && c.username) n.value = c.username;
var p = document.getElementById('dest-webdav-password'); if (p && c.password) p.value = c.password;
} else if (provider === 'sftp') {
var h = document.getElementById('dest-sftp-host'); if (h && c.host) h.value = c.host;
var po = document.getElementById('dest-sftp-port'); if (po && c.port) po.value = c.port;
var un = document.getElementById('dest-sftp-username'); if (un && c.username) un.value = c.username;
var pw = document.getElementById('dest-sftp-password'); if (pw && c.password) pw.value = c.password;
var pk = document.getElementById('dest-sftp-privatekey'); if (pk && c.privateKey) pk.value = c.privateKey;
if (c.privateKey) {
var sel = document.getElementById('dest-sftp-authtype');
if (sel) { sel.value = 'key'; sel.dispatchEvent(new Event('change')); }
}
}
} catch (e) { /* no creds yet — silent */ }
}
function collectCredentials(provider) {
if (provider === 'dropbox') {
return { token: document.getElementById('dest-dropbox-token')?.value };
}
if (provider === 'webdav') {
return {
url: document.getElementById('dest-webdav-url')?.value,
username: document.getElementById('dest-webdav-username')?.value,
password: document.getElementById('dest-webdav-password')?.value
};
}
if (provider === 'sftp') {
var auth = document.getElementById('dest-sftp-authtype')?.value;
var creds = {
host: document.getElementById('dest-sftp-host')?.value,
port: parseInt(document.getElementById('dest-sftp-port')?.value) || 22,
username: document.getElementById('dest-sftp-username')?.value
};
if (auth === 'key') creds.privateKey = document.getElementById('dest-sftp-privatekey')?.value;
else creds.password = document.getElementById('dest-sftp-password')?.value;
return creds;
}
return {};
}
async function saveCredentials(provider) {
try {
var creds = collectCredentials(provider);
var res = await secureFetch('/api/v1/backups/credentials/' + provider, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds) body: JSON.stringify({ appId, schedule: interval, retention: { keep: retention }, enabled: true })
});
var data = await res.json();
destResult(data.success ? '✅ Credentials saved' : '⚠️ ' + escapeHtml(data.error || 'Failed'), data.success);
} catch (e) {
destResult('❌ ' + escapeHtml(e.message), false);
}
}
async function clearCredentials(provider) {
if (!confirm('Delete saved ' + provider + ' credentials?')) return;
try {
var res = await secureFetch('/api/v1/backups/credentials/' + provider, { method: 'DELETE' });
var data = await res.json();
if (data.success) {
destResult('✅ Credentials cleared', true);
renderDestinationForm(provider);
} else {
destResult('⚠️ ' + escapeHtml(data.error || 'Failed'), false);
}
} catch (e) { destResult('❌ ' + escapeHtml(e.message), false); }
}
function buildDestination(type) {
var dest = { type: type };
if (type === 'local') return dest;
if (type === 'dropbox') dest.path = document.getElementById('dest-dropbox-path')?.value || '/dashcaddy-backups';
else if (type === 'webdav') dest.path = document.getElementById('dest-webdav-path')?.value || '/dashcaddy-backups';
else if (type === 'sftp') dest.path = document.getElementById('dest-sftp-path')?.value || '/dashcaddy-backups';
return dest;
}
async function testDestination(type) {
destResult('<span class="brand-spinner"></span> Testing connection...', true);
try {
var dest = buildDestination(type);
var res = await secureFetch('/api/v1/backups/test-destination', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dest)
}); });
var data = await res.json(); var data = await res.json();
if (data.success) { if (data.success) {
var ms = data.elapsedMs ? ' (' + data.elapsedMs + 'ms)' : ''; showNotification('Schedule created for ' + appId, 'success');
destResult('✅ Connection OK' + ms + ' — write/read/delete probe succeeded', true); loadSchedulesTab();
// Clear inputs
var appIdInput = document.getElementById('new-schedule-appid');
if (appIdInput) appIdInput.value = '';
} else { } else {
destResult(' ' + escapeHtml(data.error || 'Connection failed'), false); showNotification('Failed: ' + (data.error || 'Unknown'), 'error');
}
} catch (e) { destResult('❌ ' + escapeHtml(e.message), false); }
}
async function saveSchedule() {
var schedule = document.getElementById('backup-schedule-select')?.value;
var retention = parseInt(document.getElementById('backup-retention-select')?.value) || 5;
var encrypt = document.getElementById('backup-encrypt-toggle')?.checked ?? true;
var destType = document.getElementById('backup-dest-type')?.value || 'local';
var resultEl = document.getElementById('backup-schedule-result');
try {
var res = await secureFetch('/api/v1/backups/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
backups: {
auto: {
enabled: schedule !== 'disabled',
schedule: schedule === 'disabled' ? 'daily' : schedule,
include: ['all'],
encrypt: encrypt,
verify: true,
retention: { keep: retention },
destinations: [buildDestination(destType)]
}
}
})
});
var data = await res.json();
if (resultEl) {
resultEl.innerHTML = data.success ? '✅ Schedule saved' : '⚠️ ' + escapeHtml(data.error);
resultEl.style.display = 'block';
resultEl.style.background = data.success ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = data.success ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)';
setTimeout(function() { if (resultEl) resultEl.style.display = 'none'; }, 3000);
} }
} catch (e) { } catch (e) {
if (resultEl) { showNotification('Error: ' + e.message, 'error');
resultEl.innerHTML = '❌ ' + escapeHtml(e.message);
resultEl.style.display = 'block';
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)';
}
} }
} }
async function runBackupNow() { // === Backups on Disk Tab ===
var btn = document.getElementById('backup-run-now'); async function loadDiskBackups() {
var resultEl = document.getElementById('backup-schedule-result'); if (!diskContainer) return;
var destType = document.getElementById('backup-dest-type')?.value || 'local'; diskContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="brand-spinner"></span> Running...'; }
try { try {
var res = await secureFetch('/api/v1/backups/execute', { var res = await fetch('/api/v1/backups/files');
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ include: ['all'], destinations: [buildDestination(destType)] })
});
var data = await res.json(); var data = await res.json();
if (resultEl) { if (!data.success) throw new Error(data.error || 'Failed to load');
if (data.success) {
var sizeMB = data.backup?.size ? (data.backup.size / 1024 / 1024).toFixed(2) : '?'; var files = data.files || [];
resultEl.innerHTML = '✅ Backup complete (' + sizeMB + ' MB)';
resultEl.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; if (files.length === 0) {
resultEl.style.border = '1px solid var(--ok-fg)'; diskContainer.innerHTML = '<div class="panel-empty">' +
} else { '<span class="empty-icon">💾</span>' +
resultEl.innerHTML = '⚠️ ' + escapeHtml(data.error); '<div>No backup files on disk</div>' +
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; '<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Run a backup to create backup files</div>' +
resultEl.style.border = '1px solid var(--bad-fg)'; '</div>';
return;
}
// Group by appId
var byApp = {};
for (var i = 0; i < files.length; i++) {
var f = files[i];
var appId = f.appId || 'unknown';
if (!byApp[appId]) byApp[appId] = [];
byApp[appId].push(f);
}
var html = '<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 12px;">' + files.length + ' backup file(s) across ' + Object.keys(byApp).length + ' app(s)</div>';
html += '<div style="display: flex; flex-direction: column; gap: 12px;">';
var appIds = Object.keys(byApp).sort();
for (var a = 0; a < appIds.length; a++) {
var appId = appIds[a];
var appFiles = byApp[appId];
html += '<div style="background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); overflow: hidden;">' +
'<div style="padding: 8px 12px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-bottom: 1px solid var(--border); font-weight: 600; font-size: 0.85rem;">' +
escapeHtml(appId) + ' <span style="font-weight: normal; color: var(--muted); font-size: 0.75rem;">(' + appFiles.length + ' backup(s))</span>' +
'</div>';
for (var j = 0; j < appFiles.length; j++) {
var f = appFiles[j];
var dateStr = new Date(f.timestamp).toLocaleString();
html += '<div style="padding: 10px 12px; border-bottom: 1px solid var(--border);">' +
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">' +
' <div style="font-weight: 500; font-size: 0.85rem;">' + escapeHtml(f.name) + '</div>' +
' <div style="font-size: 0.75rem; color: var(--muted);">' + f.sizeFormatted + '</div>' +
'</div>' +
'<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">' + dateStr + '</div>' +
'<div style="display: flex; gap: 6px;">' +
' <button class="disk-compare-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 3px 8px; font-size: 0.75rem; background: transparent; border: 1px solid var(--accent); color: var(--accent); border-radius: 5px; cursor: pointer;">Compare</button>' +
' <button class="disk-restore-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 3px 8px; font-size: 0.75rem; background: linear-gradient(135deg, var(--ok-fg), #27ae60); border: none; color: white; border-radius: 5px; cursor: pointer; font-weight: 500;">Restore</button>' +
'</div>' +
'</div>';
} }
resultEl.style.display = 'block'; html += '</div>';
} }
loadBackupHistory(); html += '</div>';
} catch (e) {
if (resultEl) {
resultEl.innerHTML = '❌ ' + escapeHtml(e.message);
resultEl.style.display = 'block';
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)';
}
}
if (btn) { btn.disabled = false; btn.innerHTML = '▶️ Run Backup Now'; }
}
diskContainer.innerHTML = html;
// Wire up buttons
diskContainer.querySelectorAll('.disk-compare-btn').forEach(function(btn) {
btn.addEventListener('click', function() { compareBackupFile(btn.dataset.appid, btn.dataset.filename); });
});
diskContainer.querySelectorAll('.disk-restore-btn').forEach(function(btn) {
btn.addEventListener('click', function() { restoreBackupFile(btn.dataset.appid, btn.dataset.filename); });
});
} catch (e) {
diskContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
}
}
// === Backup History Tab === // === Backup History Tab ===
async function loadBackupHistory() { async function loadBackupHistory() {
if (!historyContainer) return; if (!historyContainer) return;
@@ -794,6 +731,191 @@
}; };
// Lazy-load tabs // Lazy-load tabs
document.querySelector('[data-panel="backup-automated"]')?.addEventListener('click', loadBackupSchedule); document.querySelector('[data-panel="backup-schedules-tab"]')?.addEventListener('click', loadSchedulesTab);
document.querySelector('[data-panel="backup-disk-tab"]')?.addEventListener('click', loadDiskBackups);
document.querySelector('[data-panel="backup-pointintime-tab"]')?.addEventListener('click', loadPointInTimeTab);
document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener('click', loadBackupHistory); document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener('click', loadBackupHistory);
// ===== Point-in-Time Restore Tab =====
async function loadPointInTimeTab() {
if (!pointintimeContainer) return;
// Check premium
try {
var licenseRes = await fetch('/api/v1/license/status');
var licenseData = await licenseRes.json();
if (licenseData.tier !== 'premium') {
pointintimeContainer.innerHTML = '<div class="panel-empty" style="padding: 24px; text-align: center;">' +
'<div style="font-size: 2rem; margin-bottom: 12px;">⭐</div>' +
'<div style="font-weight: 600; margin-bottom: 8px;">Premium Feature</div>' +
'<div style="font-size: 0.85rem; color: var(--muted);">Point-in-time restore requires DashCaddy Premium with auto-backup enabled.</div>' +
'<button onclick="showNotification(\'Upgrade to Premium for point-in-time restore!\', \'info\'); scrollToSection(\'license\');" style="margin-top: 16px; padding: 8px 20px; background: linear-gradient(135deg, #f39c12, #e67e22); border: none; color: white; border-radius: 8px; cursor: pointer; font-weight: 600;">Upgrade to Premium</button>' +
'</div>';
return;
}
} catch (e) { /* ignore */ }
pointintimeContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
try {
// Fetch services for dropdown
var servicesRes = await fetch('/api/v1/services');
var servicesData = await servicesRes.json();
var services = servicesData.services || [];
if (services.length === 0) {
pointintimeContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📦</span> No apps deployed yet</div>';
return;
}
var html = '<div style="padding: 8px 0 12px;">' +
'<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px;">' +
' <label style="font-size: 0.85rem; color: var(--muted); white-space: nowrap;">App:</label>' +
' <select id="pit-app-select" style="flex: 1; padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--base); max-width: 240px;">';
for (var i = 0; i < services.length; i++) {
html += '<option value="' + escapeHtml(services[i].id || services[i].name || '') + '">' +
escapeHtml(services[i].name || services[i].id || '') + '</option>';
}
html += '</select>' +
' <button id="pit-load-btn" style="padding: 8px 16px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Load Backups</button>' +
'</div>' +
'<div id="pit-backups-list" style="max-height: 320px; overflow-y: auto;"></div>' +
'</div>';
pointintimeContainer.innerHTML = html;
document.getElementById('pit-load-btn')?.addEventListener('click', function() {
var appId = document.getElementById('pit-app-select')?.value;
if (appId) loadPointInTimeBackups(appId);
});
} catch (e) {
pointintimeContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
}
}
async function loadPointInTimeBackups(appId) {
var listEl = document.getElementById('pit-backups-list');
if (!listEl) return;
listEl.innerHTML = '<div style="padding: 20px; text-align: center;"><span class="brand-spinner"></span> Loading backups...</div>';
try {
var res = await fetch('/api/v1/backups/files/' + encodeURIComponent(appId));
var data = await res.json();
if (!data.success || !data.files || data.files.length === 0) {
listEl.innerHTML = '<div class="panel-empty"><span class="empty-icon">💾</span> No backup files for ' + escapeHtml(appId) + '</div>';
return;
}
var html = '<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">' + data.files.length + ' backup(s)</div>' +
'<div style="display: flex; flex-direction: column; gap: 6px;">';
for (var i = 0; i < data.files.length; i++) {
var f = data.files[i];
var dateStr = new Date(f.timestamp).toLocaleString();
html += '<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">' +
' <div style="font-weight: 500; font-size: 0.85rem;">' + escapeHtml(f.name) + '</div>' +
' <div style="font-size: 0.75rem; color: var(--muted);">' + f.sizeFormatted + '</div>' +
'</div>' +
'<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">' + dateStr + '</div>' +
'<div style="display: flex; gap: 6px;">' +
' <button class="pit-compare-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 4px 10px; font-size: 0.75rem; background: transparent; border: 1px solid var(--accent); color: var(--accent); border-radius: 5px; cursor: pointer;">Compare</button>' +
' <button class="pit-restore-btn" data-appid="' + escapeHtml(appId) + '" data-filename="' + escapeHtml(f.name) + '" style="padding: 4px 10px; font-size: 0.75rem; background: linear-gradient(135deg, var(--ok-fg), #27ae60); border: none; color: white; border-radius: 5px; cursor: pointer; font-weight: 500;">Restore</button>' +
'</div>' +
'</div>';
}
html += '</div>';
listEl.innerHTML = html;
// Wire up buttons
listEl.querySelectorAll('.pit-compare-btn').forEach(function(btn) {
btn.addEventListener('click', function() { compareBackupFile(btn.dataset.appid, btn.dataset.filename); });
});
listEl.querySelectorAll('.pit-restore-btn').forEach(function(btn) {
btn.addEventListener('click', function() { restoreBackupFile(btn.dataset.appid, btn.dataset.filename); });
});
} catch (e) {
listEl.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
}
}
async function compareBackupFile(appId, filename) {
try {
var res = await secureFetch('/api/v1/backups/compare/' + encodeURIComponent(filename), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
var data = await res.json();
if (!data.success) {
showNotification('Compare failed: ' + (data.error || 'Unknown'), 'error');
return;
}
var diff = data.diff;
var html = '<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;" id="compare-overlay">' +
'<div style="background: var(--base); border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; padding: 20px; border: 1px solid var(--border);">' +
'<h4 style="margin: 0 0 16px;">📊 Compare: ' + escapeHtml(filename) + '</h4>' +
'<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 16px;">Size: ' + (diff.sizeFormatted || '?') + ' | Created: ' + new Date(diff.timestamp).toLocaleString() + '</div>';
if (diff.services) {
var svcChanged = diff.services.hasChanges ? '🔴' : '🟢';
html += '<div style="margin-bottom: 12px; padding: 10px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border);">' +
'<div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 4px;">' + svcChanged + ' Services (backup vs current)</div>' +
'<div style="font-size: 0.8rem; color: var(--muted);">Backup: ' + diff.services.backupCount + ' services | Current: ' + diff.services.currentCount + ' services</div>';
if (diff.services.hasChanges) {
html += '<div style="margin-top: 6px; font-size: 0.8rem; color: #f39c12;">Services differ — restoring will replace current configuration</div>';
}
html += '</div>';
}
if (diff.config) {
var cfgChanged = diff.config.hasChanges ? '🔴' : '🟢';
html += '<div style="margin-bottom: 12px; padding: 10px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border);">' +
'<div style="font-weight: 600; font-size: 0.85rem; margin-bottom: 4px;">' + cfgChanged + ' Configuration</div>';
if (diff.config.hasChanges) {
html += '<div style="font-size: 0.8rem; color: #f39c12;">Configuration differs — restoring will replace current settings</div>';
} else {
html += '<div style="font-size: 0.8rem; color: var(--ok-fg);">No changes</div>';
}
html += '</div>';
}
html += '<button id="compare-close-btn" style="padding: 8px 20px; background: var(--accent); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 500;">Close</button>' +
'</div></div>';
document.body.insertAdjacentHTML('beforeend', html);
document.getElementById('compare-close-btn')?.addEventListener('click', function() {
document.getElementById('compare-overlay')?.remove();
});
document.getElementById('compare-overlay')?.addEventListener('click', function(e) {
if (e.target === this) this.remove();
});
} catch (e) {
showNotification('Compare error: ' + e.message, 'error');
}
}
async function restoreBackupFile(appId, filename) {
if (!confirm('Restore ' + filename + ' for ' + appId + '?\n\nThis will replace current configuration, credentials, and data. Containers will be restarted.')) return;
try {
var res = await secureFetch('/api/v1/apps/' + encodeURIComponent(appId) + '/revert/' + encodeURIComponent(filename), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ restartContainers: true })
});
var data = await res.json();
if (data.success) {
showNotification(appId + ' restored to ' + filename, 'success');
setTimeout(function() { location.reload(); }, 1500);
} else {
showNotification('Restore failed: ' + (data.error || 'Unknown'), 'error');
}
} catch (e) {
showNotification('Restore error: ' + e.message, 'error');
}
}
})(); })();
+455
View File
@@ -0,0 +1,455 @@
// ========== BUNDLED WORKFLOWS ==========
(function() {
const WORKFLOW_DEFINITIONS = {
'auto-restart-on-crash': {
name: 'Auto-Restart on Crash',
icon: '🔄',
description: 'Automatically restart a container when it goes down',
trigger: 'container-down',
actions: 'restart + notify'
},
'backup-before-update': {
name: 'Backup Before Update',
icon: '💾',
description: 'Create a backup before any app update',
trigger: 'pre-update',
actions: 'backup + notify'
},
'health-check-on-interval': {
name: 'Periodic Health Check',
icon: '🏥',
description: 'Run health checks every 15 minutes and alert if degraded',
trigger: 'scheduled (15m)',
actions: 'health-check + alert'
},
'disk-space-alert': {
name: 'Disk Space Alert',
icon: '⚠️',
description: 'Alert when disk usage exceeds 80%',
trigger: 'resource-alert',
actions: 'notify'
},
'weekly-container-report': {
name: 'Weekly Container Report',
icon: '📊',
description: 'Send a weekly summary of container status and resource usage',
trigger: 'scheduled (weekly)',
actions: 'collect metrics + report'
}
};
let isPremium = false;
// === CHECK PREMIUM ===
async function checkPremium() {
try {
const resp = await fetch('/api/v1/license/feature/workflows');
const data = await resp.json();
isPremium = data.available;
} catch {
isPremium = false;
}
return isPremium;
}
// === RENDER WORKFLOW CARD ===
function renderWorkflowCard(workflow) {
const def = WORKFLOW_DEFINITIONS[workflow.id] || {
name: workflow.name || workflow.id,
icon: '⚡',
description: workflow.description || '',
trigger: workflow.trigger || 'unknown',
actions: workflow.actions ? workflow.actions.map(a => a.type).join(' + ') : ''
};
const locked = !isPremium;
const cardClass = locked ? 'workflow-card locked' : 'workflow-card';
return `<div class="${cardClass}" data-workflow="${workflow.id}">
<div class="workflow-header">
<span class="workflow-name">${def.icon} ${escapeHtml(def.name)}</span>
<label class="toggle-switch">
<input type="checkbox" class="workflow-toggle" data-workflow="${workflow.id}" ${workflow.enabled ? 'checked' : ''} ${locked ? 'disabled' : ''} />
<span class="slider"></span>
</label>
</div>
<p class="workflow-desc">${escapeHtml(def.description)}</p>
<div class="workflow-meta">
<span class="workflow-trigger"> Trigger: ${escapeHtml(def.trigger)}</span>
<span class="workflow-actions"> Actions: ${escapeHtml(def.actions)}</span>
</div>
${locked ? `<div class="workflow-locked-overlay">
<span class="lock-icon">🔒</span>
<span class="lock-text">Upgrade to Enable</span>
</div>` : ''}
<button class="btn-run-now" data-workflow="${workflow.id}" ${locked ? 'disabled' : ''}>
Run Now
</button>
</div>`;
}
// === LOAD WORKFLOWS TAB ===
async function loadWorkflowsTab() {
const container = document.getElementById('workflow-list-container');
if (!container) return;
container.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading workflows...</div>';
try {
const resp = await fetch('/api/v1/workflows');
const data = await resp.json();
if (data.success && data.workflows) {
if (data.workflows.length === 0) {
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">⚡</span>No workflows configured</div>';
return;
}
let html = '';
for (const workflow of data.workflows) {
html += renderWorkflowCard(workflow);
}
// Add non-premium banner
if (!isPremium) {
html += `<div class="accent-info-box" style="margin-top: 16px; text-align: center;">
<span>🔒 Upgrade to unlock automated workflows</span>
<button onclick="showNotification('Upgrade to Premium to enable workflows!', 'info'); scrollToSection('license');" style="margin-left: 12px; padding: 6px 16px; background: linear-gradient(135deg, #f39c12, #e67e22); border: none; color: white; border-radius: 6px; cursor: pointer; font-weight: 600;">Upgrade to Premium</button>
</div>`;
}
container.innerHTML = `<div class="workflow-list">${html}</div>`;
wireWorkflowEvents();
} else {
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">⚠️</span>Failed to load workflows</div>';
}
} catch (error) {
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">⚠️</span>Error loading workflows: ' + escapeHtml(error.message) + '</div>';
}
}
// === LOAD HISTORY TAB ===
async function loadHistoryTab() {
const container = document.getElementById('workflow-history-body');
if (!container) return;
container.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px;"><span class="brand-spinner"></span> Loading history...</td></tr>';
try {
const resp = await fetch('/api/v1/workflows/history?limit=100');
const data = await resp.json();
if (data.success && data.history && data.history.length > 0) {
let html = '';
for (const entry of data.history) {
const time = new Date(entry.timestamp).toLocaleString();
const duration = entry.duration ? entry.duration + 'ms' : '-';
const resultClass = entry.success ? 'result-success' : 'result-failure';
const resultText = entry.success ? '✓ Success' : '✗ Failed';
html += `<tr>
<td>${escapeHtml(time)}</td>
<td>${escapeHtml(entry.workflowName || entry.workflowId)}</td>
<td>${escapeHtml(entry.trigger || 'manual')}</td>
<td class="${resultClass}">${resultText}</td>
<td>${escapeHtml(duration)}</td>
</tr>`;
}
container.innerHTML = html;
} else {
container.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: var(--muted);">No workflow history yet</td></tr>';
}
} catch (error) {
container.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: var(--bad-fg);">Error loading history</td></tr>';
}
}
// === WIRE WORKFLOW EVENTS ===
function wireWorkflowEvents() {
// Toggle switches
document.querySelectorAll('.workflow-toggle').forEach(toggle => {
toggle.addEventListener('change', async function() {
const workflowId = this.dataset.workflow;
const enabled = this.checked;
const endpoint = enabled ? 'enable' : 'disable';
try {
const resp = await fetch(`/api/v1/workflows/${workflowId}/${endpoint}`, { method: 'POST' });
const data = await resp.json();
if (!data.success) {
showNotification(`Failed to ${endpoint} workflow`, 'error', 3000);
this.checked = !enabled; // revert
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error', 3000);
this.checked = !enabled; // revert
}
});
});
// Run Now buttons
document.querySelectorAll('.btn-run-now').forEach(btn => {
btn.addEventListener('click', async function() {
const workflowId = this.dataset.workflow;
const originalText = this.innerHTML;
this.innerHTML = '<span class="brand-spinner"></span>';
this.disabled = true;
try {
const resp = await fetch(`/api/v1/workflows/${workflowId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trigger: 'manual' })
});
const data = await resp.json();
if (data.success) {
showNotification(`Workflow "${WORKFLOW_DEFINITIONS[workflowId]?.name || workflowId}" executed successfully`, 'success', 3000);
} else {
showNotification(`Workflow failed: ${data.error || 'Unknown error'}`, 'error', 4000);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error', 3000);
} finally {
this.innerHTML = originalText;
this.disabled = false;
}
});
});
}
// === TAB SWITCHING ===
function setupTabSwitching() {
document.querySelectorAll('.workflows-tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
const panelId = this.dataset.panel;
// Update tab buttons
document.querySelectorAll('.workflows-tab-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
// Update panels
document.querySelectorAll('.workflow-panel').forEach(p => p.classList.remove('active'));
document.getElementById(panelId)?.classList.add('active');
// Load data for the active panel
if (panelId === 'workflows-list-panel') {
loadWorkflowsTab();
} else if (panelId === 'workflows-history-panel') {
loadHistoryTab();
}
});
});
}
// === INJECT MODAL HTML ===
injectModal('bundled-workflows-modal', `<div id="bundled-workflows-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 750px;">
<h3> Bundled Workflows</h3>
<p class="modal-subtitle">
Automated workflows to keep your system running smoothly
</p>
<!-- Tab bar -->
<div class="panel-tabs">
<button class="workflows-tab-btn panel-tab active" data-panel="workflows-list-panel">Workflows</button>
<button class="workflows-tab-btn panel-tab" data-panel="workflows-history-panel">History</button>
</div>
<!-- Tab: Workflows -->
<div id="workflows-list-panel" class="workflow-panel panel-section active">
<div id="workflow-list-container">
<div class="panel-empty">
<span class="brand-spinner"></span> Loading workflows...
</div>
</div>
</div>
<!-- Tab: History -->
<div id="workflows-history-panel" class="workflow-panel panel-section">
<div class="workflow-history">
<table>
<thead>
<tr>
<th>Time</th>
<th>Workflow</th>
<th>Trigger</th>
<th>Result</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="workflow-history-body">
<!-- populated from API -->
</tbody>
</table>
</div>
</div>
<!-- Close Button -->
<div class="weather-modal-buttons modal-footer-bar">
<button id="workflows-cancel">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('bundled-workflows-modal');
const openBtn = document.getElementById('bundled-workflows-btn');
const cancelBtn = document.getElementById('workflows-cancel');
// === STYLES ===
const style = document.createElement('style');
style.textContent = `
.workflow-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.workflow-card {
position: relative;
padding: 16px;
background: var(--card-base);
border: 1px solid var(--border);
border-radius: 10px;
transition: all 0.2s ease;
}
.workflow-card:hover {
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.workflow-card.locked {
opacity: 0.7;
}
.workflow-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.workflow-name {
font-weight: 600;
font-size: 0.95rem;
}
.workflow-desc {
font-size: 0.82rem;
color: var(--muted);
margin: 0 0 10px 0;
line-height: 1.4;
}
.workflow-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 12px;
}
.workflow-meta span {
padding: 2px 8px;
background: var(--base);
border-radius: 4px;
}
.workflow-locked-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.lock-icon {
font-size: 1.5rem;
}
.lock-text {
font-size: 0.8rem;
font-weight: 600;
color: #f39c12;
}
.workflow-history {
max-height: 400px;
overflow-y: auto;
}
.workflow-history table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.workflow-history th {
text-align: left;
padding: 8px 10px;
background: var(--base);
border-bottom: 1px solid var(--border);
font-weight: 600;
position: sticky;
top: 0;
}
.workflow-history td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.result-success {
color: var(--ok-fg);
font-weight: 500;
}
.result-failure {
color: var(--bad-fg);
font-weight: 500;
}
.workflow-panel {
display: none;
}
.workflow-panel.active {
display: block;
}
.accent-info-box {
padding: 12px 16px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
border: 1px solid var(--accent);
border-radius: 8px;
margin-bottom: 12px;
}
`;
document.head.appendChild(style);
// === MODAL EVENTS ===
openBtn?.addEventListener('click', async function() {
modal.classList.add('show');
await checkPremium();
loadWorkflowsTab();
});
setupTabSwitching();
// Close on cancel
cancelBtn?.addEventListener('click', function() {
modal.classList.remove('show');
});
// Wire modal escape key / click outside
wireModal(modal, cancelBtn);
})();
+31 -9
View File
@@ -41,8 +41,11 @@
dismissedUpdates = new Set(); dismissedUpdates = new Set();
} }
// Track global update state for cross-component access
let knownUpdates = [];
// Fetch update data and show badges // Fetch update data and show badges
async function refreshCardUpdates() { async function refreshCardUpdates(notifyNew) {
try { try {
const res = await fetch('/api/v1/updates/available'); const res = await fetch('/api/v1/updates/available');
const data = await res.json(); const data = await res.json();
@@ -51,9 +54,21 @@
// Clear all update badges first // Clear all update badges first
document.querySelectorAll('.update-available-badge').forEach(el => el.classList.remove('visible')); document.querySelectorAll('.update-available-badge').forEach(el => el.classList.remove('visible'));
if (!data.updates?.length) return; const updates = data.updates || [];
knownUpdates = updates; // store globally
for (const upd of data.updates) { // Notify if new updates appeared (periodic check with notification)
if (notifyNew && updates.length > 0) {
const prev = window._lastKnownUpdateCount || 0;
if (prev > 0 && updates.length > prev) {
showNotification(`${updates.length} container update(s) available — click Update Management to review.`, 'info');
}
window._lastKnownUpdateCount = updates.length;
}
if (!updates.length) return;
for (const upd of updates) {
// Try to match by container name to service id // Try to match by container name to service id
const apps = window.APPS || []; const apps = window.APPS || [];
for (const app of apps) { for (const app of apps) {
@@ -61,17 +76,24 @@
// Skip dismissed updates // Skip dismissed updates
if (dismissedUpdates.has(app.id)) break; if (dismissedUpdates.has(app.id)) break;
const badge = document.getElementById('update-badge-' + app.id); const badge = document.getElementById('update-badge-' + app.id);
const updateBtn = document.getElementById('update-btn-' + app.id);
if (badge) { if (badge) {
badge.classList.add('visible'); badge.classList.add('visible');
badge.title = `Image digest changed. Click to dismiss if already up to date.\n${upd.imageName || ''}`; badge.title = `Update available — click to open Update Management.`;
badge.style.cursor = 'pointer'; badge.style.cursor = 'pointer';
badge.onclick = (e) => { badge.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
badge.classList.remove('visible'); // Open Update Management modal focused on this app
dismissedUpdates.add(app.id); if (window.openUpdateModal) window.openUpdateModal(app.id);
safeSessionSet('dismissed-updates', JSON.stringify([...dismissedUpdates]));
}; };
} }
// Highlight update button if update is available
if (updateBtn) {
updateBtn.style.background = '#f97316';
updateBtn.style.borderColor = '#f97316';
updateBtn.style.boxShadow = '0 0 6px #f9731688';
updateBtn.title = `Update available — click to open Update Management.`;
}
break; break;
} }
} }
@@ -90,10 +112,10 @@
refreshCardUpdates(); refreshCardUpdates();
}, 5000); }, 5000);
// Periodic refresh every 60 seconds // Periodic refresh every 60 seconds — notify on new updates detected
setInterval(() => { setInterval(() => {
refreshCardHealth(); refreshCardHealth();
refreshCardUpdates(); refreshCardUpdates(true); // true = notify if new updates found
}, 60000); }, 60000);
} }
+59 -1
View File
@@ -168,6 +168,9 @@
<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 -->
@@ -175,9 +178,11 @@
<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>
@@ -254,6 +259,7 @@
document.getElementById('event-container-up').checked = config.events?.containerUp !== false; document.getElementById('event-container-up').checked = config.events?.containerUp !== false;
document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false; document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false;
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false; document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
document.getElementById('event-resource-alert').checked = config.events?.resourceAlert !== false;
} }
} catch (error) { } catch (error) {
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' }); errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
@@ -329,7 +335,8 @@
containerDown: document.getElementById('event-container-down').checked, containerDown: document.getElementById('event-container-down').checked,
containerUp: document.getElementById('event-container-up').checked, containerUp: document.getElementById('event-container-up').checked,
deploymentSuccess: document.getElementById('event-deploy-success').checked, deploymentSuccess: document.getElementById('event-deploy-success').checked,
deploymentFailed: document.getElementById('event-deploy-failed').checked deploymentFailed: document.getElementById('event-deploy-failed').checked,
resourceAlert: document.getElementById('event-resource-alert').checked
}, },
healthCheck: { healthCheck: {
enabled: document.getElementById('health-check-enabled').checked, enabled: document.getElementById('health-check-enabled').checked,
@@ -404,5 +411,56 @@
}); });
saveBtn?.addEventListener('click', saveNotificationConfig); saveBtn?.addEventListener('click', saveNotificationConfig);
// Send Test button - sends test notification to all enabled providers
document.getElementById('notifications-send-test')?.addEventListener('click', async () => {
const btn = document.getElementById('notifications-send-test');
const originalText = btn.textContent;
btn.textContent = 'Sending...';
btn.disabled = true;
try {
const response = 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'
})
});
const data = await response.json();
if (data.success) {
showNotification('Test notification sent!', 'success', 3000);
// Update last sent timestamp
loadNotificationStatus();
} else {
showNotification(`Test failed: ${data.results?.map(r => `${r.provider}: ${r.error || 'ok'}`).join(', ')}`, 'error', 5000);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error', 3000);
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
});
// Load notification status (last sent timestamp)
async function loadNotificationStatus() {
try {
const response = await fetch('/api/v1/notifications/status');
const data = await response.json();
if (data.success && data.lastSent) {
const lastSentEl = document.getElementById('last-notification-sent');
if (lastSentEl) {
lastSentEl.textContent = `Last sent: ${new Date(data.lastSent).toLocaleString()}`;
}
}
} catch (error) {
// Silently fail - status is not critical
}
}
wireModal(modal, cancelBtn); wireModal(modal, cancelBtn);
})(); })();
+172 -57
View File
@@ -236,70 +236,185 @@
async function loadAlerts() { async function loadAlerts() {
if (!alertsContainer) return; if (!alertsContainer) return;
alertsContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>'; alertsContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';
const data = cachedMonitoringData; const data = cachedMonitoringData;
if (!data || Object.keys(data).length === 0) { if (!data || Object.keys(data).length === 0) {
alertsContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🔔</span>No containers found. Open the Live Stats tab first.</div>'; alertsContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🔔</span>No containers found. Open the Live Stats tab first.</div>';
return; return;
} }
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
for (const [id, info] of Object.entries(data)) {
const alertCfg = info.alertConfig || {};
html += `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${info.name || id}</span>
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
<input type="checkbox" class="alert-enabled" data-container="${id}" ${alertCfg.enabled ? 'checked' : ''} /> Enabled
</label>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
<div>
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
<input type="number" class="alert-cpu" data-container="${id}" value="${alertCfg.cpuThreshold || 80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
</div>
<div>
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
<input type="number" class="alert-mem" data-container="${id}" value="${alertCfg.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="${id}" value="${alertCfg.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="${id}" ${alertCfg.autoRestart ? 'checked' : ''} /> Auto-restart on breach
</label>
<span style="flex: 1;"></span>
<button class="alert-save-btn" data-container="${id}" 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>`;
}
html += '</div>';
alertsContainer.innerHTML = html;
// Wire up save buttons // Check premium status for config section visibility
alertsContainer.querySelectorAll('.alert-save-btn').forEach(btn => { let isPremium = false;
btn.addEventListener('click', async () => { try {
const cId = btn.dataset.container; const resp = await fetch('/api/v1/license/feature/resource-alerts');
const enabled = alertsContainer.querySelector(`.alert-enabled[data-container="${cId}"]`)?.checked || false; const ld = await resp.json();
const cpuThreshold = parseInt(alertsContainer.querySelector(`.alert-cpu[data-container="${cId}"]`)?.value) || 80; isPremium = ld.available;
const memoryThreshold = parseInt(alertsContainer.querySelector(`.alert-mem[data-container="${cId}"]`)?.value) || 85; } catch (_) { isPremium = false; }
const cooldownMinutes = parseInt(alertsContainer.querySelector(`.alert-cooldown[data-container="${cId}"]`)?.value) || 15;
const autoRestart = alertsContainer.querySelector(`.alert-autorestart[data-container="${cId}"]`)?.checked || false; // Fetch alert history
try { let alertHistory = [];
const res = await secureFetch(`/api/v1/monitoring/alerts/${cId}`, { try {
method: 'POST', const hr = await fetch('/api/v1/monitoring/alerts?limit=50');
headers: { 'Content-Type': 'application/json' }, const hd = await hr.json();
body: JSON.stringify({ enabled, cpuThreshold, memoryThreshold, cooldownMinutes, autoRestart }) if (hd.success) alertHistory = hd.history || [];
}); } catch (_) {}
const data = await res.json();
btn.textContent = data.success ? '✅ Saved' : '⚠️ Failed'; // Fetch all alert configs
setTimeout(() => { btn.textContent = 'Save'; }, 2000); let allConfigs = {};
} catch (e) { try {
btn.textContent = '❌ Error'; const cr = await fetch('/api/v1/monitoring/alerts/config');
setTimeout(() => { btn.textContent = 'Save'; }, 2000); const cd = await cr.json();
} if (cd.success) allConfigs = cd.configs || {};
} catch (_) {}
const containers = Object.entries(data);
const containerRows = containers.map(([id, info]) => {
const cfg = allConfigs[id] || { cpuThreshold: 80, memoryThreshold: 90, diskIOThreshold: 50, autoRestart: false, enabled: false };
return `
<tr data-container="${id}">
<td style="font-weight: 600; padding: 8px;">${info.name || id}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${cfg.cpuThreshold ?? 80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${isPremium ? '' : 'disabled'} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${cfg.memoryThreshold ?? 90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${isPremium ? '' : 'disabled'} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${cfg.diskIOThreshold ?? 50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${isPremium ? '' : 'disabled'} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${cfg.autoRestart ? 'checked' : ''} ${isPremium ? '' : 'disabled'} /></td>
<td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${id}" data-name="${info.name || id}" 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('');
const historyRows = alertHistory.map(entry => {
const time = new Date(entry.timestamp).toLocaleString();
const notifiedMark = entry.notified ? '✓' : '—';
return `
<tr>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${time}</td>
<td style="padding: 6px 8px; font-weight: 500;">${entry.containerName || entry.containerId}</td>
<td style="padding: 6px 8px; text-transform: capitalize;">${entry.metric || entry.type}</td>
<td style="padding: 6px 8px;">${typeof entry.value === 'number' ? entry.value.toFixed(1) : entry.value}${entry.metric === 'disk' ? ' MB/s' : '%'}</td>
<td style="padding: 6px 8px; text-align: center;">${notifiedMark}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${entry.autoRestartTriggered ? '#f39c12' : 'var(--muted)'};">${entry.autoRestartTriggered ? '↻' : ''}</td>
</tr>
`;
}).join('');
const configSection = isPremium ? `
<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;"> Alert Configuration</h4>
<a href="#" id="go-to-notifications" style="font-size: 0.8rem; color: var(--accent); text-decoration: none;">Configure notifications </a>
</div>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">
<thead>
<tr style="border-bottom: 1px solid var(--border);">
<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>${containerRows}</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;"> 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>
`;
alertsContainer.innerHTML = `
${configSection}
<div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">📋 Recent Alerts</h4>
${historyRows ? `
<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);">?</th>
<th style="padding: 6px 8px;"></th>
</tr>
</thead>
<tbody>${historyRows}</tbody>
</table>
</div>
` : '<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div>
`;
// Wire up save-all button
document.getElementById('save-all-alerts')?.addEventListener('click', async () => {
const configs = {};
document.querySelectorAll('#stats-alerts-container tr[data-container]').forEach(row => {
const cId = row.dataset.container;
configs[cId] = {
cpuThreshold: parseInt(row.querySelector('.alert-cpu')?.value) || 80,
memoryThreshold: parseInt(row.querySelector('.alert-mem')?.value) || 90,
diskIOThreshold: parseInt(row.querySelector('.alert-disk')?.value) || 50,
autoRestart: !!row.querySelector('.alert-autorestart')?.checked,
enabled: true
};
}); });
try {
const res = await secureFetch('/api/v1/monitoring/alerts/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ configs })
});
const d = await res.json();
const btn = document.getElementById('save-all-alerts');
btn.textContent = d.success ? '✅ Saved' : '❌ Failed';
setTimeout(() => { btn.textContent = 'Save All'; }, 2000);
} catch (e) {
const btn = document.getElementById('save-all-alerts');
btn.textContent = '❌ Error';
setTimeout(() => { btn.textContent = 'Save All'; }, 2000);
}
});
// Wire up notification settings link
document.getElementById('go-to-notifications')?.addEventListener('click', (e) => {
e.preventDefault();
modal.classList.remove('show');
stopAutoRefresh();
document.getElementById('manage-notifications')?.click();
});
// Wire up test buttons
document.querySelectorAll('.alert-test-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const orig = btn.textContent;
btn.textContent = '...';
try {
await secureFetch(`/api/v1/monitoring/alerts/${btn.dataset.container}/test`, { method: 'POST' });
btn.textContent = '✅';
showNotification('Test alert sent for ' + btn.dataset.name, 'success', 3000);
} catch (e) {
btn.textContent = '❌';
}
setTimeout(() => { btn.textContent = orig; }, 2000);
});
});
// Wire up upgrade button
document.getElementById('upgrade-for-alerts')?.addEventListener('click', () => {
modal.classList.remove('show');
stopAutoRefresh();
if (typeof openLicenseModal === 'function') openLicenseModal();
}); });
} }
+76 -2
View File
@@ -17,8 +17,10 @@
<!-- 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;"> <div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
<button id="updates-check-btn" class="btn-accent-solid">🔍 Check for Updates</button> <button id="updates-check-btn" class="btn-accent-solid">🔍 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;"> 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">📦</span> Click "Check for Updates" to scan containers.</div> <div class="panel-empty"><span class="empty-icon">📦</span> Click "Check for Updates" to scan containers.</div>
@@ -94,13 +96,24 @@
if (updates.length === 0) { if (updates.length === 0) {
availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>'; availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>';
lastCheckSpan.textContent = ''; lastCheckSpan.textContent = '';
document.getElementById('updates-update-all-btn').style.display = 'none';
document.getElementById('updates-count-badge').style.display = 'none';
window._pendingUpdates = [];
return; return;
} }
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">'; let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">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>'; html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
for (const u of updates) { for (const u of updates) {
html += `<tr style="border-bottom: 1px solid var(--border);">`; // Match app by containerId first, then name
const appId = (() => {
const apps = window.APPS || [];
for (const a of apps) {
if (a.containerId === u.containerId || a.name === u.containerName || a.id === u.containerName) return a.id;
}
return u.containerName;
})();
html += `<tr data-app-id="${escapeHtml(appId)}" style="border-bottom: 1px solid var(--border);">`;
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.containerName)}</td>`; html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.containerName)}</td>`;
html += `<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</td>`; html += `<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</td>`;
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.currentDigest)}</code></td>`; html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.currentDigest)}</code></td>`;
@@ -114,6 +127,20 @@
availableContainer.innerHTML = html; availableContainer.innerHTML = html;
lastCheckSpan.textContent = updates.length + ' update(s) available'; lastCheckSpan.textContent = updates.length + ' update(s) available';
// Show count badge and Update All button
const countBadge = document.getElementById('updates-count-badge');
const updateAllBtn = document.getElementById('updates-update-all-btn');
if (countBadge) {
countBadge.textContent = updates.length + ' pending';
countBadge.style.display = '';
}
if (updateAllBtn && updates.length > 0) {
updateAllBtn.style.display = '';
}
// Store updates for Update All button
window._pendingUpdates = updates;
// Wire update buttons // Wire update buttons
availableContainer.querySelectorAll('.update-now-btn').forEach(btn => { availableContainer.querySelectorAll('.update-now-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
@@ -174,6 +201,38 @@
} }
} }
// Update All — sequentially, skip failures
async function updateAllContainers() {
const updates = window._pendingUpdates || [];
if (!updates.length) return;
const btn = document.getElementById('updates-update-all-btn');
if (!confirm(`Update all ${updates.length} containers? Each will restart.`)) return;
btn.textContent = '⏳ Updating...';
btn.disabled = true;
let success = 0, failed = 0;
for (const u of updates) {
try {
const r = await secureFetch(`/api/v1/updates/update/${encodeURIComponent(u.containerId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoRollback: true })
});
const d = await r.json();
if (d.success) success++;
else failed++;
} catch (_) { failed++; }
}
btn.textContent = `✅ Done`;
showNotification(`Update all: ${success} succeeded, ${failed} failed.`, success > 0 && failed === 0 ? 'success' : 'error');
setTimeout(() => {
btn.textContent = '⬆️ Update All';
btn.disabled = false;
loadAvailable();
}, 3000);
}
document.getElementById('updates-update-all-btn')?.addEventListener('click', updateAllContainers);
async function checkForUpdates() { async function checkForUpdates() {
checkBtn.textContent = '🔍 Checking...'; checkBtn.textContent = '🔍 Checking...';
checkBtn.disabled = true; checkBtn.disabled = true;
@@ -499,6 +558,21 @@
}); });
wireModal(modal, cancelBtn); wireModal(modal, cancelBtn);
// Open Update Management modal, optionally scrolled to a specific app
window.openUpdateModal = function(appId) {
modal?.classList.add('show');
loadAvailable().then(() => {
if (!appId) return;
// Scroll to and highlight the matching row
const row = availableContainer.querySelector(`[data-app-id="${appId}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.style.background = 'rgba(249,115,22,0.15)';
setTimeout(() => { row.style.background = ''; }, 3000);
}
});
};
// Lazy-load tabs // Lazy-load tabs
document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory); document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory);
document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig); document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig);