DC-005: Fix all 138 broken test paths after src/ refactor
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
This commit is contained in:
@@ -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 };
|
||||
@@ -0,0 +1,339 @@
|
||||
// DashCaddy Recipe Templates
|
||||
// Multi-container application stacks deployed as a single unit
|
||||
|
||||
const RECIPE_TEMPLATES = {
|
||||
|
||||
// === MEDIA & ENTERTAINMENT ===
|
||||
"htpc-suite": {
|
||||
name: "HTPC Suite",
|
||||
description: "Complete media automation: find, download, organize, and stream",
|
||||
icon: "\uD83C\uDFAC",
|
||||
category: "Media",
|
||||
type: "recipe",
|
||||
difficulty: "Intermediate",
|
||||
popularity: 98,
|
||||
components: [
|
||||
{
|
||||
id: "prowlarr",
|
||||
role: "Indexer Manager",
|
||||
templateRef: "prowlarr",
|
||||
required: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "qbittorrent",
|
||||
role: "Download Client",
|
||||
templateRef: "qbittorrent",
|
||||
required: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "sonarr",
|
||||
role: "TV Show Manager",
|
||||
templateRef: "sonarr",
|
||||
required: true,
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
id: "radarr",
|
||||
role: "Movie Manager",
|
||||
templateRef: "radarr",
|
||||
required: true,
|
||||
order: 4
|
||||
},
|
||||
{
|
||||
id: "lidarr",
|
||||
role: "Music Manager",
|
||||
templateRef: "lidarr",
|
||||
required: false,
|
||||
order: 5
|
||||
},
|
||||
{
|
||||
id: "overseerr",
|
||||
role: "Request Manager",
|
||||
templateRef: "seerr",
|
||||
required: false,
|
||||
order: 6
|
||||
}
|
||||
],
|
||||
sharedVolumes: {
|
||||
media: {
|
||||
label: "Media Library",
|
||||
description: "Root folder for all media (movies, TV, music)",
|
||||
defaultPath: "/media",
|
||||
usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"]
|
||||
},
|
||||
downloads: {
|
||||
label: "Downloads",
|
||||
description: "Shared downloads folder for all download clients",
|
||||
defaultPath: "/downloads",
|
||||
usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"]
|
||||
}
|
||||
},
|
||||
autoConnect: {
|
||||
enabled: true,
|
||||
description: "Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent",
|
||||
steps: [
|
||||
{ action: "configureProwlarrApps", targets: ["sonarr", "radarr", "lidarr"] },
|
||||
{ action: "configureDownloadClient", client: "qbittorrent", targets: ["sonarr", "radarr", "lidarr"] }
|
||||
]
|
||||
},
|
||||
setupInstructions: [
|
||||
"All services share the same media and downloads folders",
|
||||
"Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr",
|
||||
"Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps",
|
||||
"Add your media library root folders in Sonarr and Radarr",
|
||||
"qBittorrent is pre-configured as the download client"
|
||||
]
|
||||
},
|
||||
|
||||
// === PRODUCTIVITY ===
|
||||
"nextcloud-complete": {
|
||||
name: "Nextcloud Complete",
|
||||
description: "Full productivity suite: cloud storage, office editing, and collaboration",
|
||||
icon: "\u2601\uFE0F",
|
||||
category: "Productivity",
|
||||
type: "recipe",
|
||||
difficulty: "Intermediate",
|
||||
popularity: 90,
|
||||
components: [
|
||||
{
|
||||
id: "nextcloud-db",
|
||||
role: "Database",
|
||||
required: true,
|
||||
order: 0,
|
||||
docker: {
|
||||
image: "mariadb:11",
|
||||
ports: [],
|
||||
volumes: ["/opt/nextcloud-db/data:/var/lib/mysql"],
|
||||
environment: {
|
||||
"MYSQL_ROOT_PASSWORD": "{{GENERATED_PASSWORD}}",
|
||||
"MYSQL_DATABASE": "nextcloud",
|
||||
"MYSQL_USER": "nextcloud",
|
||||
"MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}"
|
||||
}
|
||||
},
|
||||
internal: true
|
||||
},
|
||||
{
|
||||
id: "nextcloud-redis",
|
||||
role: "Cache",
|
||||
required: true,
|
||||
order: 0,
|
||||
docker: {
|
||||
image: "redis:7-alpine",
|
||||
ports: [],
|
||||
volumes: ["/opt/nextcloud-redis/data:/data"],
|
||||
environment: {}
|
||||
},
|
||||
internal: true
|
||||
},
|
||||
{
|
||||
id: "nextcloud",
|
||||
role: "Cloud Platform",
|
||||
templateRef: "nextcloud",
|
||||
required: true,
|
||||
order: 1,
|
||||
envOverrides: {
|
||||
"MYSQL_HOST": "dashcaddy-nextcloud-db",
|
||||
"MYSQL_DATABASE": "nextcloud",
|
||||
"MYSQL_USER": "nextcloud",
|
||||
"MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}",
|
||||
"REDIS_HOST": "dashcaddy-nextcloud-redis"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "collabora",
|
||||
role: "Office Suite",
|
||||
required: false,
|
||||
order: 2,
|
||||
docker: {
|
||||
image: "collabora/code:latest",
|
||||
ports: ["{{PORT}}:9980"],
|
||||
volumes: [],
|
||||
environment: {
|
||||
"aliasgroup1": "https://{{NEXTCLOUD_DOMAIN}}",
|
||||
"extra_params": "--o:ssl.enable=false --o:ssl.termination=true"
|
||||
}
|
||||
},
|
||||
subdomain: "office",
|
||||
defaultPort: 9980,
|
||||
healthCheck: "/"
|
||||
}
|
||||
],
|
||||
network: {
|
||||
name: "dashcaddy-nextcloud",
|
||||
driver: "bridge"
|
||||
},
|
||||
sharedVolumes: {
|
||||
data: {
|
||||
label: "Cloud Storage",
|
||||
description: "Nextcloud data directory for user files",
|
||||
defaultPath: "/opt/nextcloud/data",
|
||||
usedBy: ["nextcloud"]
|
||||
}
|
||||
},
|
||||
setupInstructions: [
|
||||
"Complete the Nextcloud initial setup wizard in the browser",
|
||||
"MariaDB and Redis are pre-configured and connected",
|
||||
"If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office",
|
||||
"Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)",
|
||||
"Configure email, 2FA, and other settings in Nextcloud admin panel"
|
||||
]
|
||||
},
|
||||
|
||||
// === DEVELOPMENT ===
|
||||
"dev-environment": {
|
||||
name: "Dev Environment",
|
||||
description: "Self-hosted development workflow: Git, CI/CD, IDE, and database",
|
||||
icon: "\uD83D\uDCBB",
|
||||
category: "Development",
|
||||
type: "recipe",
|
||||
difficulty: "Advanced",
|
||||
popularity: 82,
|
||||
components: [
|
||||
{
|
||||
id: "dev-postgres",
|
||||
role: "Database",
|
||||
required: true,
|
||||
order: 0,
|
||||
docker: {
|
||||
image: "postgres:16-alpine",
|
||||
ports: [],
|
||||
volumes: ["/opt/dev-postgres/data:/var/lib/postgresql/data"],
|
||||
environment: {
|
||||
"POSTGRES_DB": "gitea",
|
||||
"POSTGRES_USER": "gitea",
|
||||
"POSTGRES_PASSWORD": "{{GENERATED_PASSWORD}}"
|
||||
}
|
||||
},
|
||||
internal: true
|
||||
},
|
||||
{
|
||||
id: "gitea",
|
||||
role: "Git Server",
|
||||
templateRef: "gitea",
|
||||
required: true,
|
||||
order: 1,
|
||||
envOverrides: {
|
||||
"GITEA__database__DB_TYPE": "postgres",
|
||||
"GITEA__database__HOST": "dashcaddy-dev-postgres:5432",
|
||||
"GITEA__database__NAME": "gitea",
|
||||
"GITEA__database__USER": "gitea",
|
||||
"GITEA__database__PASSWD": "{{GENERATED_PASSWORD}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "drone",
|
||||
role: "CI/CD Pipeline",
|
||||
templateRef: "drone",
|
||||
required: false,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "vscode-server",
|
||||
role: "Web IDE",
|
||||
templateRef: "vscode-server",
|
||||
required: false,
|
||||
order: 3
|
||||
}
|
||||
],
|
||||
network: {
|
||||
name: "dashcaddy-dev",
|
||||
driver: "bridge"
|
||||
},
|
||||
setupInstructions: [
|
||||
"Gitea is pre-configured with PostgreSQL database",
|
||||
"Complete the Gitea initial setup wizard in the browser",
|
||||
"If Drone CI is enabled, connect it to Gitea via OAuth application",
|
||||
"VS Code Server provides a full IDE in your browser",
|
||||
"All development services share a Docker network for inter-service communication"
|
||||
]
|
||||
},
|
||||
|
||||
// === HOME AUTOMATION ===
|
||||
"smart-home": {
|
||||
name: "Smart Home Hub",
|
||||
description: "Home automation: control, automate, and monitor IoT devices",
|
||||
icon: "\uD83C\uDFE0",
|
||||
category: "Home Automation",
|
||||
type: "recipe",
|
||||
difficulty: "Intermediate",
|
||||
popularity: 88,
|
||||
components: [
|
||||
{
|
||||
id: "mosquitto",
|
||||
role: "MQTT Broker",
|
||||
required: true,
|
||||
order: 0,
|
||||
docker: {
|
||||
image: "eclipse-mosquitto:2",
|
||||
ports: ["1883:1883", "9001:9001"],
|
||||
volumes: [
|
||||
"/opt/mosquitto/config:/mosquitto/config",
|
||||
"/opt/mosquitto/data:/mosquitto/data",
|
||||
"/opt/mosquitto/log:/mosquitto/log"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "mqtt",
|
||||
defaultPort: 1883,
|
||||
internal: false,
|
||||
setupNote: "MQTT broker for IoT device communication"
|
||||
},
|
||||
{
|
||||
id: "homeassistant",
|
||||
role: "Automation Hub",
|
||||
templateRef: "homeassistant",
|
||||
required: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: "nodered",
|
||||
role: "Flow Automation",
|
||||
templateRef: "nodered",
|
||||
required: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "zigbee2mqtt",
|
||||
role: "Zigbee Bridge",
|
||||
required: false,
|
||||
order: 3,
|
||||
docker: {
|
||||
image: "koenkk/zigbee2mqtt:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: ["/opt/zigbee2mqtt/data:/app/data"],
|
||||
environment: {
|
||||
"TZ": "{{TIMEZONE}}"
|
||||
}
|
||||
},
|
||||
subdomain: "zigbee",
|
||||
defaultPort: 8080,
|
||||
healthCheck: "/",
|
||||
note: "Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)"
|
||||
}
|
||||
],
|
||||
network: {
|
||||
name: "dashcaddy-smarthome",
|
||||
driver: "bridge"
|
||||
},
|
||||
setupInstructions: [
|
||||
"Mosquitto MQTT broker is ready for IoT device connections on port 1883",
|
||||
"Complete the Home Assistant onboarding wizard in the browser",
|
||||
"Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT",
|
||||
"Node-RED provides visual flow automation \u2014 connect it to MQTT for device control",
|
||||
"If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Recipe category metadata (separate from app categories)
|
||||
const RECIPE_CATEGORIES = {
|
||||
"Media": { icon: "\uD83C\uDFAC", color: "#e74c3c", description: "Media streaming and automation stacks" },
|
||||
"Productivity": { icon: "\u2601\uFE0F", color: "#3498db", description: "Cloud storage and office suites" },
|
||||
"Development": { icon: "\uD83D\uDCBB", color: "#9b59b6", description: "Self-hosted development environments" },
|
||||
"Home Automation": { icon: "\uD83C\uDFE0", color: "#27ae60", description: "IoT and smart home control" }
|
||||
};
|
||||
|
||||
module.exports = { RECIPE_TEMPLATES, RECIPE_CATEGORIES };
|
||||
Reference in New Issue
Block a user