feat: premium tier features — auto-backup scheduling, resource alerting, bundled workflows, one-click revert, notification manager

This commit is contained in:
Hermes
2026-05-27 23:39:46 -07:00
parent 6ce0a18f98
commit 11823a1466
19 changed files with 3686 additions and 407 deletions
+575
View File
@@ -0,0 +1,575 @@
/**
* Bundled Workflows - Pre-configured automation templates
*
* Workflows attach to events (container-down, pre-update, resource-alert, scheduled)
* and execute a sequence of actions when triggered.
*/
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const WORKFLOWS_FILE = process.env.WORKFLOWS_FILE || path.join(__dirname, 'workflows-config.json');
const WORKFLOW_HISTORY_FILE = process.env.WORKFLOW_HISTORY_FILE || path.join(__dirname, 'workflow-history.json');
/**
* Bundled workflow templates
*/
const BUNDLED_WORKFLOWS = {
'auto-restart-on-crash': {
id: 'auto-restart-on-crash',
name: 'Auto-Restart on Crash',
description: 'Automatically restart a container when it goes down',
trigger: 'container-down',
actions: [
{ type: 'docker-restart', containerId: '{{containerId}}' },
{ type: 'notify', message: 'Container {{containerId}} restarted automatically' }
]
},
'backup-before-update': {
id: 'backup-before-update',
name: 'Backup Before Update',
description: 'Create a backup before any app update',
trigger: 'pre-update',
actions: [
{ type: 'backup-create', appId: '{{appId}}', label: 'pre-update' },
{ type: 'notify', message: 'Backup created before updating {{appId}}' }
]
},
'health-check-on-interval': {
id: 'health-check-on-interval',
name: 'Periodic Health Check',
description: 'Run health checks every 15 minutes and alert if degraded',
trigger: 'scheduled',
interval: 15 * 60 * 1000, // 15 minutes
actions: [
{ type: 'health-check', target: '{{serviceId}}' },
{ type: 'notify-on-failure', message: 'Health check failed for {{serviceId}}' }
]
},
'disk-space-alert': {
id: 'disk-space-alert',
name: 'Disk Space Alert',
description: 'Alert when disk usage exceeds 80%',
trigger: 'resource-alert',
condition: 'diskPercent > 80',
actions: [
{ type: 'notify', message: '⚠️ Disk usage at {{diskPercent}}% on {{host}}' }
]
},
'weekly-container-report': {
id: 'weekly-container-report',
name: 'Weekly Container Report',
description: 'Send a weekly summary of container status and resource usage',
trigger: 'scheduled',
interval: 7 * 24 * 60 * 60 * 1000, // weekly
actions: [
{ type: 'collect-metrics', period: '7d' },
{ type: 'notify', message: '{{report}}' }
]
}
};
/**
* WorkflowEngine - Executes bundled workflows
*/
class WorkflowEngine extends EventEmitter {
constructor(ctx) {
super();
this.ctx = ctx;
this.enabled = new Map();
this.history = [];
this.scheduledJobs = new Map();
this.loadConfig();
this.loadHistory();
this.startScheduledWorkflows();
}
/**
* Load enabled/disabled state for workflows
*/
loadConfig() {
try {
if (fs.existsSync(WORKFLOWS_FILE)) {
const data = JSON.parse(fs.readFileSync(WORKFLOWS_FILE, 'utf8'));
this.enabled = new Map(Object.entries(data.enabled || {}));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading config:', error.message);
}
// Default all workflows to enabled if not explicitly set
for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
if (!this.enabled.has(id)) {
this.enabled.set(id, true);
}
}
}
/**
* Save enabled/disabled state
*/
saveConfig() {
try {
const data = {
enabled: Object.fromEntries(this.enabled)
};
fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving config:', error.message);
}
}
/**
* Load execution history
*/
loadHistory() {
try {
if (fs.existsSync(WORKFLOW_HISTORY_FILE)) {
this.history = JSON.parse(fs.readFileSync(WORKFLOW_HISTORY_FILE, 'utf8'));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading history:', error.message);
this.history = [];
}
}
/**
* Save execution history
*/
saveHistory() {
try {
fs.writeFileSync(WORKFLOW_HISTORY_FILE, JSON.stringify(this.history, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving history:', error.message);
}
}
/**
* Start scheduled workflows
*/
startScheduledWorkflows() {
for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
if (workflow.trigger === 'scheduled' && workflow.interval) {
this.startScheduledWorkflow(id, workflow);
}
}
}
/**
* Start a scheduled workflow
*/
startScheduledWorkflow(workflowId, workflow) {
if (!this.enabled.get(workflowId)) return;
// Clear existing job if any
this.stopScheduledWorkflow(workflowId);
const job = setInterval(() => {
this.executeWorkflow(workflowId, { trigger: 'scheduled', timestamp: new Date().toISOString() })
.catch(err => console.error(`[WorkflowEngine] Scheduled workflow ${workflowId} failed:`, err.message));
}, workflow.interval);
this.scheduledJobs.set(workflowId, job);
console.log(`[WorkflowEngine] Scheduled workflow '${workflowId}' every ${workflow.interval}ms`);
}
/**
* Stop a scheduled workflow
*/
stopScheduledWorkflow(workflowId) {
if (this.scheduledJobs.has(workflowId)) {
clearInterval(this.scheduledJobs.get(workflowId));
this.scheduledJobs.delete(workflowId);
}
}
/**
* Execute a workflow by ID
*/
async executeWorkflow(workflowId, triggerData = {}) {
const workflow = BUNDLED_WORKFLOWS[workflowId];
if (!workflow) {
throw new Error(`Unknown workflow: ${workflowId}`);
}
if (!this.enabled.get(workflowId)) {
console.log(`[WorkflowEngine] Workflow ${workflowId} is disabled, skipping`);
return { skipped: true, reason: 'disabled' };
}
const executionId = `${workflowId}-${Date.now()}`;
const startTime = Date.now();
console.log(`[WorkflowEngine] Executing workflow: ${workflowId}`);
this.emit('workflow-start', { workflowId, executionId, triggerData });
const results = [];
for (const action of workflow.actions) {
try {
const result = await this.executeAction(action, triggerData);
results.push({ action: action.type, success: true, result });
} catch (error) {
console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message);
results.push({ action: action.type, success: false, error: error.message });
// Continue with other actions but log failure
}
}
const duration = Date.now() - startTime;
const allSucceeded = results.every(r => r.success);
const historyEntry = {
executionId,
workflowId,
workflowName: workflow.name,
trigger: triggerData.trigger || 'manual',
timestamp: new Date().toISOString(),
duration,
success: allSucceeded,
results
};
this.history.push(historyEntry);
// Keep history to last 500 entries
if (this.history.length > 500) {
this.history = this.history.slice(-500);
}
this.saveHistory();
this.emit('workflow-complete', historyEntry);
console.log(`[WorkflowEngine] Workflow ${workflowId} completed in ${duration}ms, success: ${allSucceeded}`);
return historyEntry;
}
/**
* Execute a single action
*/
async executeAction(action, context) {
switch (action.type) {
case 'docker-restart':
return this.restartContainer(this.interpolate(action.containerId, context));
case 'backup-create':
return this.createBackup(
this.interpolate(action.appId, context),
action.label
);
case 'notify':
return this.notify(
this.interpolate(action.message, context),
action.channel
);
case 'notify-on-failure':
// Only send if previous action failed
return this.notify(
this.interpolate(action.message, context),
action.channel
);
case 'health-check':
return this.healthCheckService(this.interpolate(action.target, context));
case 'collect-metrics':
return this.collectMetrics(context.containerId, action.period);
default:
console.warn(`[WorkflowEngine] Unknown action type: ${action.type}`);
return { skipped: true, reason: `Unknown action type: ${action.type}` };
}
}
/**
* Interpolate template variables in a string
*/
interpolate(str, context) {
if (!str || typeof str !== 'string') return str;
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return context[key] !== undefined ? context[key] : match;
});
}
/**
* Health check action
*/
async healthCheckService(serviceId) {
if (!serviceId || serviceId === '{{serviceId}}') {
// Run health check on all services
const results = [];
const servicesStateManager = this.ctx.servicesStateManager;
if (servicesStateManager) {
const services = servicesStateManager.getState() || [];
for (const service of services) {
if (service.containerId) {
try {
const healthy = await this.checkContainerHealth(service.containerId);
results.push({ service: service.id, healthy });
} catch (e) {
results.push({ service: service.id, healthy: false, error: e.message });
}
}
}
}
return { checked: results.length, healthy: results.filter(r => r.healthy).length, results };
}
// Single service check
const healthy = await this.checkContainerHealth(serviceId);
return { serviceId, healthy };
}
/**
* Check if a container is healthy
*/
async checkContainerHealth(containerId) {
try {
const docker = this.ctx.docker?.client;
if (!docker) return false;
const container = docker.getContainer(containerId);
const info = await container.inspect();
return info.State && info.State.Running && info.State.Health !== 'unhealthy';
} catch (error) {
return false;
}
}
/**
* Docker restart action
*/
async restartContainer(containerId) {
const docker = this.ctx.docker?.client;
if (!docker) {
throw new Error('Docker client not available');
}
if (!containerId || containerId === '{{containerId}}') {
throw new Error('Container ID not provided');
}
console.log(`[WorkflowEngine] Restarting container: ${containerId}`);
const container = docker.getContainer(containerId);
await container.restart();
return { restarted: containerId };
}
/**
* Backup create action
*/
async createBackup(appId, label = 'workflow') {
const backupManager = this.ctx.backupManager;
if (!backupManager) {
throw new Error('Backup manager not available');
}
if (!appId || appId === '{{appId}}') {
throw new Error('App ID not provided');
}
console.log(`[WorkflowEngine] Creating backup for: ${appId}`);
// Use backup manager's executeBackup if available
const backupName = `${appId}-${label}`;
const backupConfig = backupManager.config?.backups?.[appId];
if (backupConfig) {
const result = await backupManager.executeBackup(backupName, backupConfig);
return { backupId: result.backupId, appId, label };
}
// Fallback: trigger manual backup via backup manager
if (backupManager.executeBackup) {
const result = await backupManager.executeBackup(appId, {
include: ['config', 'data'],
schedule: 'manual'
});
return { backupId: result.backupId, appId, label };
}
throw new Error('Backup execution not available');
}
/**
* Notify action
*/
async notify(message, channel) {
const notification = this.ctx.notification;
if (!notification) {
console.warn('[WorkflowEngine] Notification manager not available');
return { notified: false, reason: 'no notification manager' };
}
console.log(`[WorkflowEngine] Sending notification: ${message}`);
notification.send('workflow', 'Workflow Notification', message, 'info');
return { notified: true, message };
}
/**
* Collect metrics action
*/
async collectMetrics(containerId, period = '7d') {
const resourceMonitor = this.ctx.resourceMonitor;
if (!resourceMonitor) {
throw new Error('Resource monitor not available');
}
// Get aggregated stats
const stats = resourceMonitor.getAllStats();
// Build report
let report = `# Weekly Container Report\n\n`;
report += `Generated: ${new Date().toLocaleString()}\n\n`;
for (const [id, info] of Object.entries(stats)) {
const agg = info.aggregated;
report += `## ${info.name || id}\n`;
report += `- Status: ${info.current?.status || 'unknown'}\n`;
if (agg) {
report += `- CPU: avg ${agg.cpu?.avg?.toFixed(1)}%, max ${agg.cpu?.max?.toFixed(1)}%\n`;
report += `- Memory: avg ${agg.memory?.avg?.toFixed(1)}%, max ${agg.memory?.max?.toFixed(1)}%\n`;
}
report += '\n';
}
return { report, containerCount: Object.keys(stats).length };
}
/**
* List all available workflows
*/
listWorkflows() {
return Object.entries(BUNDLED_WORKFLOWS).map(([id, workflow]) => ({
...workflow,
enabled: this.enabled.get(id) ?? true
}));
}
/**
* Enable or disable a workflow
*/
setWorkflowEnabled(workflowId, enabled) {
if (!BUNDLED_WORKFLOWS[workflowId]) {
throw new Error(`Unknown workflow: ${workflowId}`);
}
this.enabled.set(workflowId, enabled);
this.saveConfig();
// Handle scheduled workflows
const workflow = BUNDLED_WORKFLOWS[workflowId];
if (workflow.trigger === 'scheduled' && workflow.interval) {
if (enabled) {
this.startScheduledWorkflow(workflowId, workflow);
} else {
this.stopScheduledWorkflow(workflowId);
}
}
console.log(`[WorkflowEngine] Workflow ${workflowId} ${enabled ? 'enabled' : 'disabled'}`);
return { workflowId, enabled };
}
/**
* Get execution history for a workflow
*/
getHistory(workflowId = null, limit = 50) {
let history = this.history;
if (workflowId) {
history = history.filter(h => h.workflowId === workflowId);
}
return history.slice(-limit).reverse();
}
/**
* Trigger workflows for a specific event
*/
async triggerForEvent(eventType, eventData) {
const matchingWorkflows = Object.entries(BUNDLED_WORKFLOWS)
.filter(([id, workflow]) => {
if (workflow.trigger !== eventType) return false;
if (!this.enabled.get(id)) return false;
// Check condition if specified
if (workflow.condition && eventData) {
try {
// Simple condition evaluation
const conditionMet = this.evaluateCondition(workflow.condition, eventData);
return conditionMet;
} catch (e) {
console.warn(`[WorkflowEngine] Condition evaluation failed for ${id}:`, e.message);
return false;
}
}
return true;
});
const results = [];
for (const [workflowId, workflow] of matchingWorkflows) {
try {
const result = await this.executeWorkflow(workflowId, {
trigger: eventType,
timestamp: new Date().toISOString(),
...eventData
});
results.push({ workflowId, success: true, result });
} catch (error) {
results.push({ workflowId, success: false, error: error.message });
}
}
return results;
}
/**
* Evaluate a simple condition string
*/
evaluateCondition(condition, data) {
// Simple condition like "diskPercent > 80"
// Supports: >, <, >=, <=, ==, !=
const match = condition.match(/^(\w+)\s*(>=|<=|==|!=|>|<)\s*(\S+)$/);
if (!match) return true;
const [, field, operator, value] = match;
const fieldValue = data[field];
if (fieldValue === undefined) return false;
const numValue = parseFloat(value);
const numFieldValue = parseFloat(fieldValue);
switch (operator) {
case '>': return numFieldValue > numValue;
case '<': return numFieldValue < numValue;
case '>=': return numFieldValue >= numValue;
case '<=': return numFieldValue <= numValue;
case '==': return fieldValue == value;
case '!=': return fieldValue != value;
default: return false;
}
}
/**
* Stop all scheduled workflows
*/
stop() {
for (const [workflowId] of this.scheduledJobs) {
this.stopScheduledWorkflow(workflowId);
}
console.log('[WorkflowEngine] All scheduled workflows stopped');
}
}
module.exports = { WorkflowEngine, BUNDLED_WORKFLOWS };