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 @@
- + + +
@@ -143,12 +145,32 @@ - -
-
+ +
+
- Loading backup schedule... + Loading schedules... +
+
+
+ + +
+
+
+ 💾 + Loading backup files... +
+
+
+ + +
+
+
+ + Loading...
@@ -181,8 +203,10 @@ var previewContent = document.getElementById('backup-preview-content'); var restoreBtn = document.getElementById('backup-do-restore-btn'); 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 diskContainer = document.getElementById('backup-disk-container'); + var pointintimeContainer = document.getElementById('pointintime-container'); var selectedBackup = null; @@ -383,359 +407,272 @@ restoreBtn.innerHTML = '⚡ Restore Everything'; }); - // === Automated Backups Tab === - // Holds the destination currently being edited in the form - var currentDestination = { type: 'local' }; - - async function loadBackupSchedule() { + // === Schedules Tab (Premium) === + async function loadSchedulesTab() { if (!scheduleContainer) return; + scheduleContainer.innerHTML = '
Loading...
'; try { - var res = await fetch('/api/v1/backups/config'); + var res = await fetch('/api/v1/backups/schedule'); var data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to load config'); - var cfg = data.config?.backups || {}; - var autoKey = Object.keys(cfg)[0]; - var auto = autoKey ? cfg[autoKey] : null; - - // Pull existing destination (first one) — fall back to local - var existingDest = (auto?.destinations && auto.destinations[0]) || { type: 'local' }; - currentDestination = JSON.parse(JSON.stringify(existingDest)); - - var html = '
'; - html += '

⏰ Backup Schedule

'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; + + if (data.premiumRequired) { + scheduleContainer.innerHTML = '
' + + '
' + + '
Premium Feature
' + + '
Auto-backup scheduling requires a DashCaddy Premium subscription.
' + + '' + + '
'; + return; + } + + if (!data.success) throw new Error(data.error || 'Failed to load schedules'); + + var schedules = data.schedules || []; + + if (schedules.length === 0) { + scheduleContainer.innerHTML = '
' + + '' + + '
No backup schedules configured
' + + '
Select apps below to enable auto-backup
' + + '
'; + return; + } + + var html = '
'; + for (var i = 0; i < schedules.length; i++) { + 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 += '
' + + '
' + + '
' + escapeHtml(sch.appId) + '
' + + ' ' + + '
' + + '
' + + '
Schedule:
' + + '
Keep last:
' + + '
' + + '
' + + '
Next run: ' + escapeHtml(nextRunStr) + '
' + + '
Last run: ' + escapeHtml(lastRunStr) + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + } html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += ' '; - html += ' '; - html += '
'; - html += '
'; - - // === Destination Section === - html += '
'; - html += '

☁️ Backup Destination

'; - html += '
'; - html += '
'; - html += '
'; - html += ''; - html += '
'; - - html += ''; + + // Add "Add Schedule" section at bottom + html += '
' + + '

➕ Add New Schedule

' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
'; + scheduleContainer.innerHTML = html; - - document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule); - document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow); - - var destTypeSel = document.getElementById('backup-dest-type'); - destTypeSel?.addEventListener('change', function() { - currentDestination = { type: destTypeSel.value }; - renderDestinationForm(destTypeSel.value); + + // Wire up event listeners + scheduleContainer.querySelectorAll('.schedule-toggle').forEach(function(toggle) { + toggle.addEventListener('change', function() { updateSchedule(toggle.dataset.appid, { enabled: toggle.checked }); }); }); - 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) { - scheduleContainer.innerHTML = '
Failed to load schedule: ' + escapeHtml(e.message) + '
'; + scheduleContainer.innerHTML = '
Failed to load: ' + escapeHtml(e.message) + '
'; } } - - // Render the provider-specific form fields and load saved credentials (masked) - async function renderDestinationForm(type) { - var formEl = document.getElementById('backup-dest-form'); - if (!formEl) return; - - if (type === 'local') { - formEl.innerHTML = '
Backups are stored on the host filesystem. No additional configuration required.
'; + + async function updateSchedule(appId, updates) { + try { + var res = await secureFetch('/api/v1/backups/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId, ...updates }) + }); + 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; } - - var html = ''; - - if (type === 'dropbox') { - html += ''; - html += ''; - html += ''; - html += ''; - html += '
Generate a token at Dropbox App Console with files.content.write + files.content.read scopes.
'; - } else if (type === 'webdav') { - html += ''; - html += ''; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += ''; - html += ''; - } else if (type === 'sftp') { - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += '
'; - html += '
'; - html += ''; - html += ''; - html += ''; - } - - html += '
'; - html += ' '; - html += ' '; - html += ' '; - html += '
'; - - formEl.innerHTML = html; - - // SFTP auth type toggle - if (type === 'sftp') { - var authSel = document.getElementById('dest-sftp-authtype'); - var pwRow = document.getElementById('dest-sftp-password-row'); - var keyRow = document.getElementById('dest-sftp-key-row'); - authSel?.addEventListener('change', function() { - if (authSel.value === 'key') { pwRow.style.display = 'none'; keyRow.style.display = ''; } - else { pwRow.style.display = ''; keyRow.style.display = 'none'; } - }); - } - - document.getElementById('dest-save-creds')?.addEventListener('click', function() { saveCredentials(type); }); - document.getElementById('dest-test-conn')?.addEventListener('click', function() { testDestination(type); }); - document.getElementById('dest-clear-creds')?.addEventListener('click', function() { clearCredentials(type); }); - - // Pull existing (masked) credentials - await loadCredentials(type); - } - - function destResult(msg, ok) { - var el = document.getElementById('backup-dest-result'); - if (!el) return; - el.innerHTML = msg; - el.style.display = 'block'; - el.style.background = ok ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; - el.style.border = ok ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)'; - } - - async function loadCredentials(provider) { + try { - var res = await fetch('/api/v1/backups/credentials/' + provider); - var data = await res.json(); - if (!data.success || !data.credentials) return; - var c = data.credentials; - if (provider === 'dropbox') { - var t = document.getElementById('dest-dropbox-token'); if (t && c.token) t.value = c.token; - } else if (provider === 'webdav') { - var u = document.getElementById('dest-webdav-url'); if (u && c.url) u.value = c.url; - var n = document.getElementById('dest-webdav-username'); if (n && c.username) n.value = c.username; - var p = document.getElementById('dest-webdav-password'); if (p && c.password) p.value = c.password; - } else if (provider === 'sftp') { - var h = document.getElementById('dest-sftp-host'); if (h && c.host) h.value = c.host; - var po = document.getElementById('dest-sftp-port'); if (po && c.port) po.value = c.port; - var un = document.getElementById('dest-sftp-username'); if (un && c.username) un.value = c.username; - var pw = document.getElementById('dest-sftp-password'); if (pw && c.password) pw.value = c.password; - var pk = document.getElementById('dest-sftp-privatekey'); if (pk && c.privateKey) pk.value = c.privateKey; - if (c.privateKey) { - var sel = document.getElementById('dest-sftp-authtype'); - if (sel) { sel.value = 'key'; sel.dispatchEvent(new Event('change')); } - } - } - } catch (e) { /* no creds yet — silent */ } - } - - function collectCredentials(provider) { - if (provider === 'dropbox') { - return { token: document.getElementById('dest-dropbox-token')?.value }; - } - if (provider === 'webdav') { - return { - url: document.getElementById('dest-webdav-url')?.value, - username: document.getElementById('dest-webdav-username')?.value, - password: document.getElementById('dest-webdav-password')?.value - }; - } - if (provider === 'sftp') { - var auth = document.getElementById('dest-sftp-authtype')?.value; - var creds = { - host: document.getElementById('dest-sftp-host')?.value, - port: parseInt(document.getElementById('dest-sftp-port')?.value) || 22, - username: document.getElementById('dest-sftp-username')?.value - }; - if (auth === 'key') creds.privateKey = document.getElementById('dest-sftp-privatekey')?.value; - else creds.password = document.getElementById('dest-sftp-password')?.value; - return creds; - } - return {}; - } - - async function saveCredentials(provider) { - try { - var creds = collectCredentials(provider); - var res = await secureFetch('/api/v1/backups/credentials/' + provider, { + var res = await secureFetch('/api/v1/backups/schedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(creds) - }); - var data = await res.json(); - destResult(data.success ? '✅ Credentials saved' : '⚠️ ' + escapeHtml(data.error || 'Failed'), data.success); - } catch (e) { - destResult('❌ ' + escapeHtml(e.message), false); - } - } - - async function clearCredentials(provider) { - if (!confirm('Delete saved ' + provider + ' credentials?')) return; - try { - var res = await secureFetch('/api/v1/backups/credentials/' + provider, { method: 'DELETE' }); - var data = await res.json(); - if (data.success) { - destResult('✅ Credentials cleared', true); - renderDestinationForm(provider); - } else { - destResult('⚠️ ' + escapeHtml(data.error || 'Failed'), false); - } - } catch (e) { destResult('❌ ' + escapeHtml(e.message), false); } - } - - function buildDestination(type) { - var dest = { type: type }; - if (type === 'local') return dest; - if (type === 'dropbox') dest.path = document.getElementById('dest-dropbox-path')?.value || '/dashcaddy-backups'; - else if (type === 'webdav') dest.path = document.getElementById('dest-webdav-path')?.value || '/dashcaddy-backups'; - else if (type === 'sftp') dest.path = document.getElementById('dest-sftp-path')?.value || '/dashcaddy-backups'; - return dest; - } - - async function testDestination(type) { - destResult(' Testing connection...', true); - try { - var dest = buildDestination(type); - var res = await secureFetch('/api/v1/backups/test-destination', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(dest) + body: JSON.stringify({ appId, schedule: interval, retention: { keep: retention }, enabled: true }) }); var data = await res.json(); if (data.success) { - var ms = data.elapsedMs ? ' (' + data.elapsedMs + 'ms)' : ''; - destResult('✅ Connection OK' + ms + ' — write/read/delete probe succeeded', true); + showNotification('Schedule created for ' + appId, 'success'); + loadSchedulesTab(); + // Clear inputs + var appIdInput = document.getElementById('new-schedule-appid'); + if (appIdInput) appIdInput.value = ''; } else { - destResult('❌ ' + escapeHtml(data.error || 'Connection failed'), false); - } - } catch (e) { destResult('❌ ' + escapeHtml(e.message), false); } - } - - async function saveSchedule() { - var schedule = document.getElementById('backup-schedule-select')?.value; - var retention = parseInt(document.getElementById('backup-retention-select')?.value) || 5; - var encrypt = document.getElementById('backup-encrypt-toggle')?.checked ?? true; - var destType = document.getElementById('backup-dest-type')?.value || 'local'; - var resultEl = document.getElementById('backup-schedule-result'); - try { - var res = await secureFetch('/api/v1/backups/config', { - 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); + showNotification('Failed: ' + (data.error || 'Unknown'), 'error'); } } 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)'; - } + showNotification('Error: ' + e.message, 'error'); } } - - async function runBackupNow() { - var btn = document.getElementById('backup-run-now'); - var resultEl = document.getElementById('backup-schedule-result'); - var destType = document.getElementById('backup-dest-type')?.value || 'local'; - if (btn) { btn.disabled = true; btn.innerHTML = ' Running...'; } + + // === Backups on Disk Tab === + async function loadDiskBackups() { + if (!diskContainer) return; + diskContainer.innerHTML = '
Loading...
'; try { - var res = await secureFetch('/api/v1/backups/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ include: ['all'], destinations: [buildDestination(destType)] }) - }); + var res = await fetch('/api/v1/backups/files'); var data = await res.json(); - if (resultEl) { - if (data.success) { - var sizeMB = data.backup?.size ? (data.backup.size / 1024 / 1024).toFixed(2) : '?'; - resultEl.innerHTML = '✅ Backup complete (' + sizeMB + ' MB)'; - resultEl.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; - resultEl.style.border = '1px solid var(--ok-fg)'; - } else { - resultEl.innerHTML = '⚠️ ' + escapeHtml(data.error); - resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; - resultEl.style.border = '1px solid var(--bad-fg)'; + if (!data.success) throw new Error(data.error || 'Failed to load'); + + var files = data.files || []; + + if (files.length === 0) { + diskContainer.innerHTML = '
' + + '💾' + + '
No backup files on disk
' + + '
Run a backup to create backup files
' + + '
'; + 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 = '
' + files.length + ' backup file(s) across ' + Object.keys(byApp).length + ' app(s)
'; + html += '
'; + + var appIds = Object.keys(byApp).sort(); + for (var a = 0; a < appIds.length; a++) { + var appId = appIds[a]; + var appFiles = byApp[appId]; + html += '
' + + '
' + + escapeHtml(appId) + ' (' + appFiles.length + ' backup(s))' + + '
'; + + for (var j = 0; j < appFiles.length; j++) { + var f = appFiles[j]; + var dateStr = new Date(f.timestamp).toLocaleString(); + html += '
' + + '
' + + '
' + escapeHtml(f.name) + '
' + + '
' + f.sizeFormatted + '
' + + '
' + + '
' + dateStr + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; } - resultEl.style.display = 'block'; + html += '
'; } - loadBackupHistory(); - } 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'; } - } + html += '
'; + 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 = '
Failed: ' + escapeHtml(e.message) + '
'; + } + } + // === Backup History Tab === async function loadBackupHistory() { if (!historyContainer) return; @@ -794,6 +731,191 @@ }; // 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); + + // ===== 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 = '
' + + '
' + + '
Premium Feature
' + + '
Point-in-time restore requires DashCaddy Premium with auto-backup enabled.
' + + '' + + '
'; + return; + } + } catch (e) { /* ignore */ } + + pointintimeContainer.innerHTML = '
Loading...
'; + + 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 = '
📦 No apps deployed yet
'; + return; + } + + var html = '
' + + '
' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
'; + + 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 = '
Failed: ' + escapeHtml(e.message) + '
'; + } + } + + async function loadPointInTimeBackups(appId) { + var listEl = document.getElementById('pit-backups-list'); + if (!listEl) return; + + listEl.innerHTML = '
Loading backups...
'; + + 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 = '
💾 No backup files for ' + escapeHtml(appId) + '
'; + return; + } + + var html = '
' + data.files.length + ' backup(s)
' + + '
'; + + for (var i = 0; i < data.files.length; i++) { + var f = data.files[i]; + var dateStr = new Date(f.timestamp).toLocaleString(); + html += '
' + + '
' + + '
' + escapeHtml(f.name) + '
' + + '
' + f.sizeFormatted + '
' + + '
' + + '
' + dateStr + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + } + html += '
'; + 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 = '
Failed: ' + escapeHtml(e.message) + '
'; + } + } + + 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 = '
' + + '
' + + '

📊 Compare: ' + escapeHtml(filename) + '

' + + '
Size: ' + (diff.sizeFormatted || '?') + ' | Created: ' + new Date(diff.timestamp).toLocaleString() + '
'; + + if (diff.services) { + var svcChanged = diff.services.hasChanges ? '🔴' : '🟢'; + html += '
' + + '
' + svcChanged + ' Services (backup vs current)
' + + '
Backup: ' + diff.services.backupCount + ' services | Current: ' + diff.services.currentCount + ' services
'; + if (diff.services.hasChanges) { + html += '
Services differ — restoring will replace current configuration
'; + } + html += '
'; + } + + if (diff.config) { + var cfgChanged = diff.config.hasChanges ? '🔴' : '🟢'; + html += '
' + + '
' + cfgChanged + ' Configuration
'; + if (diff.config.hasChanges) { + html += '
Configuration differs — restoring will replace current settings
'; + } else { + html += '
No changes
'; + } + html += '
'; + } + + html += '' + + '
'; + + 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'); + } + } })(); diff --git a/status/js/bundled-workflows.js b/status/js/bundled-workflows.js new file mode 100644 index 0000000..c725f09 --- /dev/null +++ b/status/js/bundled-workflows.js @@ -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 `
+
+ ${def.icon} ${escapeHtml(def.name)} + +
+

${escapeHtml(def.description)}

+
+ ⚡ Trigger: ${escapeHtml(def.trigger)} + ▶ Actions: ${escapeHtml(def.actions)} +
+ ${locked ? `
+ 🔒 + Upgrade to Enable +
` : ''} + +
`; + } + + // === LOAD WORKFLOWS TAB === + async function loadWorkflowsTab() { + const container = document.getElementById('workflow-list-container'); + if (!container) return; + + container.innerHTML = '
Loading workflows...
'; + + 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 = '
No workflows configured
'; + return; + } + + let html = ''; + for (const workflow of data.workflows) { + html += renderWorkflowCard(workflow); + } + + // Add non-premium banner + if (!isPremium) { + html += `
+ 🔒 Upgrade to unlock automated workflows + +
`; + } + + container.innerHTML = `
${html}
`; + wireWorkflowEvents(); + } else { + container.innerHTML = '
⚠️Failed to load workflows
'; + } + } catch (error) { + container.innerHTML = '
⚠️Error loading workflows: ' + escapeHtml(error.message) + '
'; + } + } + + // === LOAD HISTORY TAB === + async function loadHistoryTab() { + const container = document.getElementById('workflow-history-body'); + if (!container) return; + + container.innerHTML = ' Loading history...'; + + 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 += ` + ${escapeHtml(time)} + ${escapeHtml(entry.workflowName || entry.workflowId)} + ${escapeHtml(entry.trigger || 'manual')} + ${resultText} + ${escapeHtml(duration)} + `; + } + container.innerHTML = html; + } else { + container.innerHTML = 'No workflow history yet'; + } + } catch (error) { + container.innerHTML = 'Error loading history'; + } + } + + // === 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 = ''; + 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', `
+
+

⚡ Bundled Workflows

+ + + +
+ + +
+ + +
+
+
+ Loading workflows... +
+
+
+ + +
+
+ + + + + + + + + + + + + +
TimeWorkflowTriggerResultDuration
+
+
+ + + +
+
`); + + 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); +})(); \ No newline at end of file diff --git a/status/js/notification-settings.js b/status/js/notification-settings.js index 8d41809..1b6f79a 100644 --- a/status/js/notification-settings.js +++ b/status/js/notification-settings.js @@ -168,6 +168,9 @@ +
@@ -175,9 +178,11 @@
No notifications yet
+
@@ -254,6 +259,7 @@ document.getElementById('event-container-up').checked = config.events?.containerUp !== false; document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false; document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false; + document.getElementById('event-resource-alert').checked = config.events?.resourceAlert !== false; } } catch (error) { errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' }); @@ -329,7 +335,8 @@ containerDown: document.getElementById('event-container-down').checked, containerUp: document.getElementById('event-container-up').checked, deploymentSuccess: document.getElementById('event-deploy-success').checked, - deploymentFailed: document.getElementById('event-deploy-failed').checked + deploymentFailed: document.getElementById('event-deploy-failed').checked, + resourceAlert: document.getElementById('event-resource-alert').checked }, healthCheck: { enabled: document.getElementById('health-check-enabled').checked, @@ -404,5 +411,56 @@ }); 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); })(); diff --git a/status/js/resource-monitor.js b/status/js/resource-monitor.js index 360894a..a98bd28 100644 --- a/status/js/resource-monitor.js +++ b/status/js/resource-monitor.js @@ -236,70 +236,185 @@ async function loadAlerts() { if (!alertsContainer) return; alertsContainer.innerHTML = '
Loading alerts...
'; + const data = cachedMonitoringData; if (!data || Object.keys(data).length === 0) { alertsContainer.innerHTML = '
🔔No containers found. Open the Live Stats tab first.
'; return; } - let html = '
'; - for (const [id, info] of Object.entries(data)) { - const alertCfg = info.alertConfig || {}; - html += `
-
- ${info.name || id} - -
-
-
- - -
-
- - -
-
- - -
-
-
- - - -
-
`; - } - html += '
'; - alertsContainer.innerHTML = html; - // Wire up save buttons - alertsContainer.querySelectorAll('.alert-save-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const cId = btn.dataset.container; - const enabled = alertsContainer.querySelector(`.alert-enabled[data-container="${cId}"]`)?.checked || false; - const cpuThreshold = parseInt(alertsContainer.querySelector(`.alert-cpu[data-container="${cId}"]`)?.value) || 80; - const memoryThreshold = parseInt(alertsContainer.querySelector(`.alert-mem[data-container="${cId}"]`)?.value) || 85; - const cooldownMinutes = parseInt(alertsContainer.querySelector(`.alert-cooldown[data-container="${cId}"]`)?.value) || 15; - const autoRestart = alertsContainer.querySelector(`.alert-autorestart[data-container="${cId}"]`)?.checked || false; - try { - const res = await secureFetch(`/api/v1/monitoring/alerts/${cId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled, cpuThreshold, memoryThreshold, cooldownMinutes, autoRestart }) - }); - const data = await res.json(); - btn.textContent = data.success ? '✅ Saved' : '⚠️ Failed'; - setTimeout(() => { btn.textContent = 'Save'; }, 2000); - } catch (e) { - btn.textContent = '❌ Error'; - setTimeout(() => { btn.textContent = 'Save'; }, 2000); - } + // Check premium status for config section visibility + let isPremium = false; + try { + const resp = await fetch('/api/v1/license/feature/resource-alerts'); + const ld = await resp.json(); + isPremium = ld.available; + } catch (_) { isPremium = false; } + + // Fetch alert history + let alertHistory = []; + try { + const hr = await fetch('/api/v1/monitoring/alerts?limit=50'); + const hd = await hr.json(); + if (hd.success) alertHistory = hd.history || []; + } catch (_) {} + + // Fetch all alert configs + let allConfigs = {}; + try { + const cr = await fetch('/api/v1/monitoring/alerts/config'); + 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 ` + + ${info.name || id} + + + + + + + + + `; + }).join(''); + + const historyRows = alertHistory.map(entry => { + const time = new Date(entry.timestamp).toLocaleString(); + const notifiedMark = entry.notified ? '✓' : '—'; + return ` + + ${time} + ${entry.containerName || entry.containerId} + ${entry.metric || entry.type} + ${typeof entry.value === 'number' ? entry.value.toFixed(1) : entry.value}${entry.metric === 'disk' ? ' MB/s' : '%'} + ${notifiedMark} + ${entry.autoRestartTriggered ? '↻' : ''} + + `; + }).join(''); + + const configSection = isPremium ? ` +
+
+

⚙️ Alert Configuration

+ Configure notifications → +
+
+ + + + + + + + + + + + ${containerRows} +
ContainerCPU %Mem %Disk I/O MB/sAuto-Restart
+
+
+ +
+
+ ` : ` +
+ ⭐ Premium Feature +

Upgrade to configure resource alert thresholds per container.

+ +
+ `; + + alertsContainer.innerHTML = ` + ${configSection} +
+

📋 Recent Alerts

+ ${historyRows ? ` +
+ + + + + + + + + + + + ${historyRows} +
TimeContainerMetricValue✓?
+
+ ` : '
No alerts recorded yet.
'} +
+ `; + + // 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(); }); }