From 0cda298651ee35296a284e129efac2c27a983d83 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 12 Aug 2026 12:50:39 -0700 Subject: [PATCH] =?UTF-8?q?[grade=3DB]=20DC-105:=20Smart=20defaults=20wiza?= =?UTF-8?q?rd=20=E2=80=94=20'What=20do=20you=20want=20to=20self-host=3F'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 endpoints: - GET /api/v1/wizard/categories — list 6 categories with icons - POST /api/v1/wizard/recommend — get prioritized service list from selected categories - POST /api/v1/wizard/apply — generate deployment plan Categories: media-streaming, file-sync, home-network, smart-home, development, monitoring. Hardware profiles: minimal (3 svcs), medium (6), powerful (12). Cross-category dedup with priority sorting. 1633 tests pass. --- dashcaddy-api/routes/wizard.js | 171 +++++++++++++++++++++++++++++++++ dashcaddy-api/src/app.js | 7 ++ 2 files changed, 178 insertions(+) create mode 100644 dashcaddy-api/routes/wizard.js diff --git a/dashcaddy-api/routes/wizard.js b/dashcaddy-api/routes/wizard.js new file mode 100644 index 0000000..454719a --- /dev/null +++ b/dashcaddy-api/routes/wizard.js @@ -0,0 +1,171 @@ +/** + * DC-105: Smart defaults wizard — "What do you want to self-host?" + * + * Guides users through initial setup by asking what they want to host, + * then generates optimal configuration based on their hardware and needs. + * + * POST /api/v1/wizard/recommend — returns recommended services based on answers + * POST /api/v1/wizard/apply — applies the wizard configuration + */ +const express = require('express'); +const { ok, errorResponse } = require('../src/utils/responses'); + +// Recommendation matrix: user intent → suggested services +const RECOMMENDATIONS = { + 'media-streaming': { + label: 'Media Streaming', + icon: '🎬', + services: [ + { template: 'plex', priority: 1, reason: 'Stream movies, TV shows, and music' }, + { template: 'sonarr', priority: 2, reason: 'Automatically download TV shows' }, + { template: 'radarr', priority: 2, reason: 'Automatically download movies' }, + { template: 'qbittorrent', priority: 3, reason: 'Download client for media' }, + { template: 'prowlarr', priority: 3, reason: 'Indexer management' }, + ], + }, + 'file-sync': { + label: 'File Storage & Sync', + icon: '📁', + services: [ + { template: 'nextcloud', priority: 1, reason: 'Self-hosted Google Drive alternative' }, + { template: 'vaultwarden', priority: 2, reason: 'Password manager (Bitwarden compatible)' }, + ], + }, + 'home-network': { + label: 'Home Network', + icon: '🌐', + services: [ + { template: 'adguard', priority: 1, reason: 'Network-wide ad blocking' }, + { template: 'wireguard', priority: 2, reason: 'VPN for remote access' }, + { template: 'pihole', priority: 3, reason: 'Alternative DNS ad blocker' }, + ], + }, + 'smart-home': { + label: 'Smart Home', + icon: '🏠', + services: [ + { template: 'homeassistant', priority: 1, reason: 'Central smart home automation' }, + { template: 'mosquitto', priority: 2, reason: 'MQTT broker for IoT devices' }, + ], + }, + 'development': { + label: 'Development', + icon: '💻', + services: [ + { template: 'gitea', priority: 1, reason: 'Self-hosted Git with CI/CD' }, + { template: 'code', priority: 2, reason: 'VS Code in the browser' }, + { template: 'portainer', priority: 2, reason: 'Docker container management' }, + ], + }, + 'monitoring': { + label: 'Monitoring & Analytics', + icon: '📊', + services: [ + { template: 'grafana', priority: 1, reason: 'Beautiful dashboards and graphs' }, + { template: 'prometheus', priority: 2, reason: 'Time-series metrics collection' }, + { template: 'uptimekuma', priority: 2, reason: 'Uptime monitoring with alerts' }, + ], + }, +}; + +module.exports = function({ APP_TEMPLATES, asyncHandler }) { + const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)); + const router = express.Router(); + + // GET /api/v1/wizard/categories — list available categories + router.get('/wizard/categories', wrap(async (req, res) => { + ok(res, { + categories: Object.entries(RECOMMENDATIONS).map(([key, val]) => ({ + id: key, + label: val.label, + icon: val.icon, + serviceCount: val.services.length, + })), + }); + })); + + // POST /api/v1/wizard/recommend — get recommendations based on selected categories + router.post('/wizard/recommend', wrap(async (req, res) => { + const { categories = [], hardwareProfile = 'medium' } = req.body || {}; + + if (!Array.isArray(categories) || categories.length === 0) { + return errorResponse(res, 400, 'categories array is required (at least one)'); + } + + // Collect all recommended services from selected categories + const recommended = new Map(); + for (const cat of categories) { + const rec = RECOMMENDATIONS[cat]; + if (!rec) continue; + for (const svc of rec.services) { + if (!recommended.has(svc.template)) { + recommended.set(svc.template, { ...svc, categories: [cat] }); + } else { + recommended.get(svc.template).categories.push(cat); + } + } + } + + // Sort by priority (lower = more important) + const sorted = [...recommended.values()].sort((a, b) => a.priority - b.priority); + + // Adjust based on hardware profile + const limits = { + minimal: { maxServices: 3, maxMemory: '512m' }, + medium: { maxServices: 6, maxMemory: '1g' }, + powerful: { maxServices: 12, maxMemory: '2g' }, + }; + const profile = limits[hardwareProfile] || limits.medium; + const filtered = sorted.slice(0, profile.maxServices); + + // Enrich with template details + const enriched = filtered.map(svc => { + const template = (APP_TEMPLATES || []).find(t => + (t.id || t.name?.toLowerCase().replace(/\s+/g, '-')) === svc.template + ); + return { + ...svc, + available: !!template, + image: template?.image || null, + ports: template?.ports || [], + estimatedMemory: template?.memory || '256m', + }; + }); + + ok(res, { + hardwareProfile, + categories: categories.filter(c => RECOMMENDATIONS[c]), + totalRecommended: enriched.length, + services: enriched, + resourceLimits: profile, + }); + })); + + // POST /api/v1/wizard/apply — deploy the selected services + // (Delegates to the existing deploy endpoint for each service) + router.post('/wizard/apply', wrap(async (req, res) => { + const { services = [], subdomainPrefix = '' } = req.body || {}; + + if (!Array.isArray(services) || services.length === 0) { + return errorResponse(res, 400, 'services array is required (at least one template ID)'); + } + + // Return deployment plan — actual deployment happens via the existing + // POST /api/v1/apps/deploy endpoint for each service + const plan = services.map((templateId, index) => ({ + step: index + 1, + templateId, + subdomain: `${subdomainPrefix}${templateId}`.toLowerCase(), + deployEndpoint: '/api/v1/apps/deploy', + status: 'pending', + })); + + ok(res, { + totalSteps: plan.length, + plan, + message: 'Use POST /api/v1/apps/deploy for each step to execute', + }); + })); + + return router; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 46af92f..096cb92 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -64,6 +64,7 @@ const i18nRoutes = require('../routes/i18n'); const discoverRoutes = require('../routes/discover'); const discoverAdoptRoutes = require('../routes/discover-adopt'); const catalogRoutes = require('../routes/catalog'); +const wizardRoutes = require('../routes/wizard'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -625,6 +626,12 @@ async function createApp() { APP_TEMPLATES: require('./docker/app-templates'), asyncHandler: ctx.asyncHandler, })); + + // DC-105: Smart defaults wizard + apiRouter.use(wizardRoutes({ + APP_TEMPLATES: require('./docker/app-templates'), + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,