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
+1 -1
View File
@@ -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)); }
+187
View File
@@ -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];
}
+500 -1
View File
@@ -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];
}
+81 -2
View File
@@ -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' });
+41
View File
@@ -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;
};
+272
View File
@@ -0,0 +1,272 @@
const express = require('express');
const http = require('http');
/**
* OpenClaw management routes
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
*
* GET /openclaw/status → container info + gateway health
* POST /openclaw/deploy → deploy OpenClaw container
* GET /openclaw/proxy/* → proxy GET to gateway
* POST /openclaw/proxy/* → proxy POST to gateway
* DELETE /openclaw → remove container
*/
module.exports = function openClawRoutes(ctx) {
const router = express.Router();
const docker = ctx.docker;
const asyncHandler = ctx.asyncHandler;
const log = ctx.log || console;
// ── helpers ──────────────────────────────────────────────────────────────
async function findOpenClawContainer() {
const containers = await docker.client.listContainers({ all: true });
return containers.find(function(c) {
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
c.Names.some(function(n) { return n.includes('openclaw'); }));
}) || null;
}
async function getGatewayToken(containerId) {
try {
const info = await docker.client.containerInfo(containerId);
const entry = (info.Config.Env || []).find(function(e) {
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
});
return entry ? entry.split('=')[1] : null;
} catch(err) {
return null;
}
}
async function getContainerPort(containerId) {
try {
const containers = await docker.client.listContainers({ all: true });
const c = containers.find(function(x) {
return x.Id === containerId || x.Id.startsWith(containerId);
});
if (c && c.Ports) {
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
if (p && p.PublicPort) return String(p.PublicPort);
}
return '18792';
} catch(err) {
return '18792';
}
}
async function gatewayHealth(baseUrl, token) {
return new Promise(function(resolve) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
let data = '';
res.on('data', function(d) { data += d; });
res.on('end', function() {
try { resolve({ ok: true, data: JSON.parse(data) }); }
catch(e) { resolve({ ok: true, data: data }); }
});
});
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
});
}
function proxyRequest(req, res, targetBase, path, token) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
headers['X-Forwarded-For'] = req.ip;
headers['X-Forwarded-Proto'] = req.protocol;
const url = targetBase + '/' + path;
const method = req.method;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const body = JSON.stringify(req.body);
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(body);
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
proxyReq.write(body);
proxyReq.end();
} else {
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
}
}
// ── GET /openclaw/status ────────────────────────────────────────────────
router.get('/status', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) {
return res.json({ success: true, deployed: false });
}
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const health = await gatewayHealth(baseUrl, token);
res.json({
success: true,
deployed: true,
container: {
id: container.Id.slice(0, 12),
name: container.Name,
state: container.State,
status: container.Status,
created: container.Created,
image: container.Image
},
gateway: {
url: baseUrl,
port: port,
healthy: health.ok,
healthData: health.data || null,
tokenSet: !!token
}
});
}));
// ── POST /openclaw/deploy ───────────────────────────────────────────────
router.post('/deploy', asyncHandler(async function(req, res) {
const existing = await findOpenClawContainer();
if (existing) {
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
}
const image = 'ghcr.io/nousresearch/openclaw:latest';
const name = 'openclaw-' + Date.now();
const gatewayToken = generateToken();
// Pull image
log.info('Pulling ' + image + '...');
try {
await new Promise(function(resolve, reject) {
docker.client.pull(image, function(err, stream) {
if (err) return reject(err);
docker.client.modem.followProgress(stream, function(err2) {
if (err2) return reject(err2);
resolve();
});
});
});
} catch(e) {
log.error('OpenClaw pull failed: ' + e.message);
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
}
// Create + start container
try {
const container = await docker.client.createContainer({
name: name,
Image: image,
Env: [
'OPENCLAW_GATEWAY_MODE=local',
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
],
HostConfig: {
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
RestartPolicy: { Name: 'unless-stopped' },
Labels: {
'dashcaddy.managed': 'true',
'dashcaddy.app': 'openclaw'
}
},
ExposedPorts: { '18792/tcp': {} }
});
await container.start();
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
res.json({
success: true,
deployed: true,
container: { id: container.id.slice(0, 12), name: name },
gateway: {
url: 'http://localhost:18792',
token: gatewayToken
}
});
} catch(e) {
log.error('OpenClaw deploy failed: ' + e.message);
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
}
}));
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
router.get('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
router.post('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── DELETE /openclaw ───────────────────────────────────────────────────
router.delete('/', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
try {
const c = docker.client.container(container.Id);
await c.stop().catch(function() {});
await c.remove({ force: true });
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
res.json({ success: true, message: 'OpenClaw removed' });
} catch(e) {
log.error('Failed to remove OpenClaw: ' + e.message);
res.status(500).json({ success: false, error: e.message });
}
}));
return router;
};
// ── token generator ──────────────────────────────────────────────────────────
function generateToken() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
+65
View File
@@ -0,0 +1,65 @@
const express = require('express');
/**
* Workflows routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.workflowEngine - WorkflowEngine instance
* @param {Object} deps.licenseManager - License manager for premium gating
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @returns {express.Router}
*/
module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
const router = express.Router();
// Apply premium gating to all workflows routes
router.use(licenseManager.requirePremium('workflows'));
// ===== WORKFLOW MANAGEMENT ENDPOINTS =====
// List all bundled workflows
router.get('/workflows', asyncHandler(async (req, res) => {
const workflows = workflowEngine.listWorkflows();
res.json({ success: true, workflows });
}, 'workflows-list'));
// Enable a workflow
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
res.json({ success: true, ...result });
}, 'workflows-enable'));
// Disable a workflow
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
res.json({ success: true, ...result });
}, 'workflows-disable'));
// Manually trigger a workflow
router.post('/workflows/:workflowId/run', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const triggerData = req.body || {};
triggerData.trigger = 'manual';
const result = await workflowEngine.executeWorkflow(workflowId, triggerData);
res.json({ success: true, result });
}, 'workflows-run'));
// Get execution history for a workflow
router.get('/workflows/:workflowId/history', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const limit = parseInt(req.query.limit) || 50;
const history = workflowEngine.getHistory(workflowId, limit);
res.json({ success: true, history });
}, 'workflows-history'));
// Get all workflow execution history
router.get('/workflows/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit);
res.json({ success: true, history });
}, 'workflows-all-history'));
return router;
};