diff --git a/dashcaddy-api/backup-manager.js b/dashcaddy-api/backup-manager.js index e1eb40c..57c1bb8 100644 --- a/dashcaddy-api/backup-manager.js +++ b/dashcaddy-api/backup-manager.js @@ -20,6 +20,14 @@ class BackupManager extends EventEmitter { this.history = this.loadHistory(); this.scheduledJobs = new Map(); this.running = false; + this.notificationManager = null; + } + + /** + * Set the notification manager for sending backup notifications + */ + setNotificationManager(nm) { + this.notificationManager = nm; } /** @@ -177,6 +185,14 @@ class BackupManager extends EventEmitter { } this.emit('backup-complete', historyEntry); + + // Send notification if manager is configured + if (this.notificationManager) { + this.notificationManager.sendBackupComplete(historyEntry).catch(err => { + console.error('[BackupManager] Failed to send backup-complete notification:', err.message); + }); + } + console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`); return historyEntry; @@ -193,7 +209,14 @@ class BackupManager extends EventEmitter { this.addToHistory(historyEntry); this.emit('backup-failed', historyEntry); - + + // Send notification if manager is configured + if (this.notificationManager) { + this.notificationManager.sendBackupFailed(historyEntry).catch(err => { + console.error('[BackupManager] Failed to send backup-failed notification:', err.message); + }); + } + throw error; } } diff --git a/dashcaddy-api/bundled-workflows.js b/dashcaddy-api/bundled-workflows.js new file mode 100644 index 0000000..142ef1a --- /dev/null +++ b/dashcaddy-api/bundled-workflows.js @@ -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 }; \ No newline at end of file diff --git a/dashcaddy-api/notification-manager.js b/dashcaddy-api/notification-manager.js new file mode 100644 index 0000000..d5a3f0f --- /dev/null +++ b/dashcaddy-api/notification-manager.js @@ -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: `
${body}`
+ });
+
+ 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;
\ No newline at end of file
diff --git a/dashcaddy-api/resource-monitor.js b/dashcaddy-api/resource-monitor.js
index 61f9abd..d81d7cf 100644
--- a/dashcaddy-api/resource-monitor.js
+++ b/dashcaddy-api/resource-monitor.js
@@ -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_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_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_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
@@ -35,11 +36,21 @@ class ResourceMonitor extends EventEmitter {
this.dailyHistory = new Map(); // containerId -> { name, samples: [...] } (daily avg, 365d)
this.alerts = new Map(); // containerId -> alert config
this.lastAlerts = new Map(); // containerId -> last alert timestamp
+ this.alertHistory = []; // alert history entries
+ this.notificationManager = null;
this.loadStats();
this.loadHourlyStats();
this.loadDailyStats();
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) {
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,
containerName,
timestamp: new Date().toISOString(),
alerts,
stats,
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
if (alertConfig.autoRestart) {
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(),
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) {
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
*/
@@ -430,6 +523,62 @@ class ResourceMonitor extends EventEmitter {
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
*/
diff --git a/dashcaddy-api/routes/apps/index.js b/dashcaddy-api/routes/apps/index.js
index 2b2fa03..aeaabef 100644
--- a/dashcaddy-api/routes/apps/index.js
+++ b/dashcaddy-api/routes/apps/index.js
@@ -55,7 +55,7 @@ module.exports = function(ctx) {
try { router.use('/apps', initTemplates(subCtx)); }
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); }
try { router.use('/compose', initCompose(subCtx)); }
diff --git a/dashcaddy-api/routes/apps/restore.js b/dashcaddy-api/routes/apps/restore.js
index 4fb65e6..7c9d2f4 100644
--- a/dashcaddy-api/routes/apps/restore.js
+++ b/dashcaddy-api/routes/apps/restore.js
@@ -1,6 +1,10 @@
const express = require('express');
+const path = require('path');
+const fs = require('fs');
const { DOCKER } = require('../../constants');
+const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
+
/**
* Apps restore routes factory
* @param {Object} deps - Explicit dependencies
@@ -122,6 +126,180 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
res.json({ success: true, services: 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.
*/
@@ -309,3 +487,12 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
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];
+}
diff --git a/dashcaddy-api/routes/backups.js b/dashcaddy-api/routes/backups.js
index 3f7208c..a2e7a00 100644
--- a/dashcaddy-api/routes/backups.js
+++ b/dashcaddy-api/routes/backups.js
@@ -1,16 +1,470 @@
const express = require('express');
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
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.backupManager - Backup management module
+ * @param {Object} deps.licenseManager - License manager for premium gating
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @returns {express.Router}
*/
-module.exports = function({ backupManager, asyncHandler }) {
+module.exports = function({ backupManager, licenseManager, asyncHandler }) {
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
router.get('/backups/config', asyncHandler(async (req, res) => {
const config = backupManager.getConfig();
@@ -154,3 +608,48 @@ module.exports = function({ backupManager, asyncHandler }) {
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];
+}
diff --git a/dashcaddy-api/routes/monitoring.js b/dashcaddy-api/routes/monitoring.js
index 760d042..ae27a39 100644
--- a/dashcaddy-api/routes/monitoring.js
+++ b/dashcaddy-api/routes/monitoring.js
@@ -10,7 +10,7 @@ const { success } = require('../response-helpers');
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
-module.exports = function({ resourceMonitor, docker, asyncHandler, log }) {
+module.exports = function({ resourceMonitor, docker, asyncHandler, log, notificationManager }) {
const router = express.Router();
// ===== RESOURCE MONITORING ENDPOINTS =====
@@ -66,7 +66,86 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log }) {
success(res, { aggregated, hours });
}, '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) => {
resourceMonitor.setAlertConfig(req.params.containerId, req.body);
success(res, { message: 'Alert configuration saved' });
diff --git a/dashcaddy-api/routes/notifications.js b/dashcaddy-api/routes/notifications.js
index e87621f..aebffaf 100644
--- a/dashcaddy-api/routes/notifications.js
+++ b/dashcaddy-api/routes/notifications.js
@@ -218,5 +218,46 @@ module.exports = function({ notification, asyncHandler }) {
});
}, '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;
};
diff --git a/dashcaddy-api/routes/openclaw.js b/dashcaddy-api/routes/openclaw.js
new file mode 100644
index 0000000..c4e6689
--- /dev/null
+++ b/dashcaddy-api/routes/openclaw.js
@@ -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;
+}
diff --git a/dashcaddy-api/routes/workflows.js b/dashcaddy-api/routes/workflows.js
new file mode 100644
index 0000000..87f93d0
--- /dev/null
+++ b/dashcaddy-api/routes/workflows.js
@@ -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;
+};
\ No newline at end of file
diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js
index 1d70f7e..421b6e5 100644
--- a/dashcaddy-api/server.js
+++ b/dashcaddy-api/server.js
@@ -67,9 +67,35 @@ process.on('uncaughtException', (error) => {
const portLockManager = require('./port-lock-manager');
// Optional modules
- let dockerMaintenance, logDigest;
+ let dockerMaintenance, logDigest, bundledWorkflows;
try { dockerMaintenance = require('./docker-maintenance'); } 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');
@@ -81,6 +107,10 @@ process.on('uncaughtException', (error) => {
// Resource monitoring
try {
resourceMonitor.start();
+ // Connect workflow engine to resource monitor for resource-alert events
+ if (workflowEngine) {
+ resourceMonitor.setWorkflowEngine(workflowEngine);
+ }
log.info('server', 'Resource monitoring started');
} catch (err) {
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 });
}
+ // Connect workflow engine to update manager for pre-update events
+ if (workflowEngine) {
+ updateManager.setWorkflowEngine(workflowEngine);
+ }
+
// Health checker (with service sync)
(async () => {
try {
diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js
index 0769b5f..f3e2c6c 100644
--- a/dashcaddy-api/src/app.js
+++ b/dashcaddy-api/src/app.js
@@ -39,6 +39,13 @@ let dockerMaintenance, logDigest;
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
+// Workflow engine (bundled workflows)
+let bundledWorkflowsModule;
+let workflowEngine = null;
+try {
+ bundledWorkflowsModule = require('../bundled-workflows');
+} catch (_) { /* optional module */ }
+
// Templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
@@ -69,6 +76,7 @@ const recipesRoutes = require('../routes/recipes');
const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events');
+const workflowsRoutes = require('../routes/workflows');
// Constants
const { APP } = require('../constants');
@@ -308,9 +316,55 @@ async function createApp() {
app,
});
+ // Initialize workflow engine if bundled-workflows is available
+ if (bundledWorkflowsModule && ctx.docker) {
+ try {
+ const { WorkflowEngine } = bundledWorkflowsModule;
+ const workflowCtx = {
+ docker: ctx.docker,
+ notification: ctx.notification,
+ backupManager: ctx.backupManager,
+ resourceMonitor: ctx.resourceMonitor,
+ servicesStateManager: ctx.servicesStateManager
+ };
+ workflowEngine = new WorkflowEngine(workflowCtx);
+ ctx.workflowEngine = workflowEngine;
+ log.info('app', 'Workflow engine initialized');
+ } catch (err) {
+ log.error('app', 'Failed to initialize workflow engine', { error: err.message });
+ }
+ }
+
// Build versioned API router
const apiRouter = express.Router();
-
+
+ // Wire up notification listeners for resourceMonitor and backupManager
+ if (ctx.notification && ctx.resourceMonitor) {
+ ctx.resourceMonitor.on('alert', (alertData) => {
+ ctx.notification.sendAlert(alertData).catch(err => {
+ log.error('notification', 'Failed to send alert', { error: err.message });
+ });
+ });
+ ctx.resourceMonitor.on('auto-restart', (data) => {
+ ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
+ log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
+ });
+ });
+ }
+
+ if (ctx.notification && ctx.backupManager) {
+ ctx.backupManager.on('backup-complete', (data) => {
+ ctx.notification.send('backup-complete', data).catch(err => {
+ log.error('notification', 'Failed to send backup-complete', { error: err.message });
+ });
+ });
+ ctx.backupManager.on('backup-failed', (data) => {
+ ctx.notification.send('backup-failed', data).catch(err => {
+ log.error('notification', 'Failed to send backup-failed', { error: err.message });
+ });
+ });
+ }
+
// Mount route modules
apiRouter.use(authRoutes(ctx));
apiRouter.use(configRoutes(ctx));
@@ -361,7 +415,8 @@ async function createApp() {
resourceMonitor: ctx.resourceMonitor,
docker: ctx.docker,
asyncHandler: ctx.asyncHandler,
- log: ctx.log
+ log: ctx.log,
+ notificationManager: ctx.notification
}));
apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager,
@@ -404,6 +459,7 @@ async function createApp() {
}));
apiRouter.use(backupsRoutes({
backupManager: ctx.backupManager,
+ licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/ca', caRoutes(ctx));
@@ -434,6 +490,11 @@ async function createApp() {
updateManager: ctx.updateManager,
logError: ctx.logError
}));
+ apiRouter.use(workflowsRoutes({
+ workflowEngine: ctx.workflowEngine,
+ licenseManager: ctx.licenseManager,
+ asyncHandler: ctx.asyncHandler
+ }));
// Inline API routes
apiRouter.get('/health', (req, res) => {
diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js
index 92baa80..9e07d17 100644
--- a/dashcaddy-api/src/context/index.js
+++ b/dashcaddy-api/src/context/index.js
@@ -6,6 +6,7 @@ const { createDockerContext } = require('./docker');
const { createCaddyContext } = require('./caddy');
const { createDnsContext } = require('./dns');
const { createSessionContext } = require('./session');
+const NotificationManager = require('../../notification-manager');
/**
* Assemble the full application context
@@ -85,11 +86,14 @@ function assembleContext({
const dns = createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
const session = createSessionContext(middlewareResult);
- // Notification context (inline for now - could be extracted)
- const notification = {
- // These will be populated by server.js for now
- // TODO: Extract notification module
- };
+ // Create notification manager
+ const notification = new NotificationManager({
+ NOTIFICATIONS_FILE,
+ fetchT,
+ docker,
+ log,
+ config: siteConfig
+ });
// Tailscale context (inline for now - could be extracted)
const tailscale = {
diff --git a/dashcaddy-api/update-manager.js b/dashcaddy-api/update-manager.js
index 9d25af5..384359e 100644
--- a/dashcaddy-api/update-manager.js
+++ b/dashcaddy-api/update-manager.js
@@ -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
*/
@@ -281,6 +313,12 @@ class UpdateManager extends EventEmitter {
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
console.log(`[UpdateManager] Pulling latest image: ${imageName}`);
await this.pullImage(imageName);
diff --git a/status/js/backup-restore.js b/status/js/backup-restore.js
index 0fd1866..54d0224 100644
--- a/status/js/backup-restore.js
+++ b/status/js/backup-restore.js
@@ -94,7 +94,9 @@
${escapeHtml(def.description)}
+ + ${locked ? `` : ''} + +| Container | +CPU % | +Mem % | +Disk I/O MB/s | +Auto-Restart | ++ |
|---|
Upgrade to configure resource alert thresholds per container.
+ +| Time | +Container | +Metric | +Value | +✓? | ++ |
|---|