diff --git a/dashcaddy-api/app-templates.js b/dashcaddy-api/app-templates.js index e5b13b8..533bd86 100644 --- a/dashcaddy-api/app-templates.js +++ b/dashcaddy-api/app-templates.js @@ -158,6 +158,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v3/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"], setupInstructions: [ "Configure download clients (qBittorrent, etc.)", "Add indexers for content discovery", @@ -190,7 +191,8 @@ const APP_TEMPLATES = { defaultPort: 7878, healthCheck: "/api/v3/system/status", subpathSupport: 'native', - urlBaseEnv: 'URL_BASE' + urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"] }, "prowlarr": { @@ -1172,6 +1174,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v1/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"], setupInstructions: [ "Configure download clients", "Add indexers", @@ -1205,6 +1208,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v1/system/status", subpathSupport: 'native', urlBaseEnv: 'URL_BASE', + dependsOn: ["prowlarr", "qbittorrent"], setupInstructions: [ "Configure download clients", "Add indexers for books", @@ -1238,6 +1242,7 @@ const APP_TEMPLATES = { healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'BASE_URL', + dependsOn: ["sonarr", "radarr"], setupInstructions: [ "Connect to Sonarr and Radarr", "Configure subtitle providers", @@ -1266,6 +1271,7 @@ const APP_TEMPLATES = { healthCheck: "/api/v1/status", subpathSupport: 'native', urlBaseEnv: 'BASE_PATH', + dependsOn: ["sonarr", "radarr"], setupInstructions: [ "Connect to Plex, Jellyfin, or Emby server", "Link Sonarr and Radarr", @@ -1295,6 +1301,7 @@ const APP_TEMPLATES = { healthCheck: "/", subpathSupport: 'native', urlBaseEnv: 'TAUTULLI_HTTP_ROOT', + dependsOn: ["plex"], setupInstructions: [ "Connect to Plex server", "Configure notifications", diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index b419b17..1fd71be 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -226,6 +226,198 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag } } + /** + * Check which template-declared dependencies are missing from running services. + * Returns null if the template has no dependsOn field. + * @param {Object} template - App template with optional `dependsOn: string[]` + * @param {Array} runningServices - Array of services from services.json + * @returns {{ dependsOn: string[], missing: string[], satisfied: string[] } | null} + */ + function checkDependencies(template, runningServices) { + if (!template || !Array.isArray(template.dependsOn) || template.dependsOn.length === 0) { + return null; + } + const deployedTemplateIds = new Set( + (runningServices || []) + .map(s => s.appTemplate || s.deploymentManifest?.templateId) + .filter(Boolean) + ); + const satisfied = []; + const missing = []; + for (const depId of template.dependsOn) { + if (deployedTemplateIds.has(depId)) satisfied.push(depId); + else missing.push(depId); + } + return { dependsOn: [...template.dependsOn], missing, satisfied }; + } + + /** + * Topologically sort a list of template IDs by their dependsOn relationships. + * Dependencies come before dependents. Skips IDs not present in templates. + * Detects cycles and breaks them by emitting in DFS finish order. + * @param {string[]} ids + * @returns {string[]} + */ + function topologicalSortTemplates(ids) { + const idSet = new Set(ids); + const visited = new Set(); + const visiting = new Set(); + const result = []; + + function visit(id) { + if (visited.has(id)) return; + if (visiting.has(id)) return; // cycle — break here + visiting.add(id); + const tpl = ctx.APP_TEMPLATES[id]; + if (tpl && Array.isArray(tpl.dependsOn)) { + for (const dep of tpl.dependsOn) { + if (idSet.has(dep)) visit(dep); + } + } + visiting.delete(id); + visited.add(id); + result.push(id); + } + + for (const id of ids) visit(id); + return result; + } + + /** + * Build a default deploy config for a dependency template. + * Uses the template's defaults: subdomain, port, IP from siteConfig. + */ + function buildDefaultDepConfig(template) { + return { + subdomain: template.subdomain, + port: template.defaultPort, + ip: ctx.siteConfig.dnsServerIp || 'localhost', + createDns: true, + tailscaleOnly: false, + allowedIPs: [] + }; + } + + /** + * Deploy a list of dependency apps in topological order, waiting for each + * to become healthy before continuing. Skips deps already running. + * Throws on first failure. + */ + async function deployDependencyChain(depIds) { + const ordered = topologicalSortTemplates(depIds); + const services = await servicesStateManager.read(); + const deployedTemplateIds = new Set( + services.map(s => s.appTemplate || s.deploymentManifest?.templateId).filter(Boolean) + ); + + const deployed = []; + for (const depId of ordered) { + if (deployedTemplateIds.has(depId)) { + log.info('deploy', 'Dependency already running, skipping', { depId }); + continue; + } + const depTemplate = ctx.APP_TEMPLATES[depId]; + if (!depTemplate) { + throw new Error(`[DC-205] Dependency "${depId}" has no template`); + } + const depConfig = buildDefaultDepConfig(depTemplate); + log.info('deploy', 'Deploying dependency', { depId, subdomain: depConfig.subdomain }); + + const containerId = await deployContainer(depId, depConfig, depTemplate); + await helpers.waitForHealthCheck(containerId, depTemplate.healthCheck, depConfig.port); + log.info('deploy', 'Dependency healthy', { depId, containerId }); + + // Generate Caddy config + DNS for the dep so it's actually reachable + const caddyOptions = { + tailscaleOnly: false, + allowedIPs: [], + subpathSupport: depTemplate.subpathSupport || 'strip' + }; + const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain; + const depCaddyConfig = caddy.generateConfig(depConfig.subdomain, depConfig.ip, depConfig.port, caddyOptions); + if (isSubdirectoryMode) { + await helpers.ensureMainDomainBlock(); + await helpers.addSubpathConfig(depConfig.subdomain, depCaddyConfig); + } else { + await helpers.addCaddyConfig(depConfig.subdomain, depCaddyConfig); + } + if (!isSubdirectoryMode) { + try { + await ctx.dns.createRecord(depConfig.subdomain, depConfig.ip); + } catch (dnsErr) { + log.warn('deploy', 'DNS creation failed for dependency', { depId, error: dnsErr.message }); + } + } + + // Build a minimal manifest for the dep so it's restorable later + const processedDep = helpers.processTemplateVariables(depTemplate, depConfig); + const depManifest = { + templateId: depId, + config: { + subdomain: depConfig.subdomain, + port: depConfig.port, + ip: depConfig.ip, + createDns: depConfig.createDns, + tailscaleOnly: false, + allowedIPs: [], + useExisting: false + }, + container: { + image: processedDep.docker.image, + ports: processedDep.docker.ports, + volumes: processedDep.docker.volumes || [], + environment: { ...processedDep.docker.environment }, + capabilities: processedDep.docker.capabilities || undefined + }, + caddy: { + tailscaleOnly: false, + allowedIPs: [], + subpathSupport: depTemplate.subpathSupport || 'strip', + routingMode: ctx.siteConfig.routingMode + } + }; + + await ctx.addServiceToConfig({ + id: depConfig.subdomain, + name: depTemplate.name, + logo: depTemplate.logo || `/assets/${depId}.png`, + url: ctx.buildServiceUrl(depConfig.subdomain), + containerId, + appTemplate: depId, + tailscaleOnly: false, + routingMode: ctx.siteConfig.routingMode, + deployedAt: new Date().toISOString(), + deploymentManifest: depManifest + }); + + deployed.push({ depId, containerId, subdomain: depConfig.subdomain }); + } + return deployed; + } + + // Check dependencies for an app (frontend calls this before opening deploy modal) + router.post('/apps/check-dependencies', asyncHandler(async (req, res) => { + const { appId } = req.body; + if (!appId || typeof appId !== 'string') { + throw new ValidationError('appId is required'); + } + const template = ctx.APP_TEMPLATES[appId]; + if (!template) throw new ValidationError('Invalid app template'); + + const services = await servicesStateManager.read(); + const result = checkDependencies(template, services); + if (!result) { + return res.json({ appId, dependsOn: [], missing: [], satisfied: [] }); + } + // Enrich missing deps with friendly names for the UI + const missingDetails = result.missing.map(id => ({ + id, + name: ctx.APP_TEMPLATES[id]?.name || id, + icon: ctx.APP_TEMPLATES[id]?.icon || '📦' + })); + res.json({ appId, ...result, missingDetails }); + }, 'apps-check-dependencies')); + // Check for existing container before deployment router.post('/apps/check-existing', asyncHandler(async (req, res) => { const { appId } = req.body; @@ -241,7 +433,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag // Deploy new app router.post('/apps/deploy', asyncHandler(async (req, res) => { - const { appId, config } = req.body; + const { appId, config, deployDependencies } = req.body; if (!appId || typeof appId !== 'string') { throw new ValidationError('appId is required'); } @@ -251,6 +443,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag if (!config.subdomain || typeof config.subdomain !== 'string') { throw new ValidationError('config.subdomain is required'); } + let deployedDeps = []; try { log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); const template = ctx.APP_TEMPLATES[appId]; @@ -259,6 +452,13 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag throw new ValidationError('Invalid app template'); } + // Deploy declared dependencies first if requested + if (Array.isArray(deployDependencies) && deployDependencies.length > 0) { + log.info('deploy', 'Deploying dependencies first', { appId, deps: deployDependencies }); + deployedDeps = await deployDependencyChain(deployDependencies); + log.info('deploy', 'Dependencies deployed', { count: deployedDeps.length }); + } + if (config.subdomain) { if (!REGEX.SUBDOMAIN.test(config.subdomain)) { throw new ValidationError('[DC-301] Invalid subdomain format'); @@ -409,7 +609,8 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag success: true, containerId, usedExisting, url: serviceUrl, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, - setupInstructions: template.setupInstructions || [] + setupInstructions: template.setupInstructions || [], + deployedDependencies: deployedDeps }; if (dnsWarning) response.warning = dnsWarning; diff --git a/dashcaddy-api/routes/recipes/deploy.js b/dashcaddy-api/routes/recipes/deploy.js index 79c6faa..1db14d4 100644 --- a/dashcaddy-api/routes/recipes/deploy.js +++ b/dashcaddy-api/routes/recipes/deploy.js @@ -70,6 +70,9 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi const deployedComponents = []; const errors = []; + // Lazy-load apps helpers once for health waits between components + const appsHelpers = require('../apps/helpers')(ctx); + try { for (const component of componentsToDeploy) { try { @@ -84,6 +87,18 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi log.info('recipe', `Component deployed: ${component.id}`, { containerId: result.containerId?.substring(0, 12) }); + + // Wait for the component to become healthy before deploying the next one + // (so dependent components — Sonarr after Prowlarr — see a working dep) + if (result.containerId && result.healthPort) { + try { + await appsHelpers.waitForHealthCheck(result.containerId, result.healthPath, result.healthPort); + log.info('recipe', `Component healthy: ${component.id}`); + } catch (healthErr) { + log.warn('recipe', `Component health wait failed: ${component.id}`, { error: healthErr.message }); + // Don't abort the recipe — continue with the next component + } + } } catch (componentError) { log.error('recipe', `Component failed: ${component.id}`, { error: componentError.message @@ -349,6 +364,17 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi } } + // Resolve health check path + port for the waiter + let healthPath = null; + let healthPort = null; + if (component.templateRef) { + const tpl = ctx.APP_TEMPLATES[component.templateRef]; + healthPath = tpl?.healthCheck || null; + healthPort = port || tpl?.defaultPort || null; + } else if (dockerConfig.ports?.length > 0) { + healthPort = port || dockerConfig.ports[0].split(/[:/]/)[0]; + } + return { id: component.id, role: component.role, @@ -358,7 +384,9 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi internal: component.internal || false, templateRef: component.templateRef, logo, - url + url, + healthPath, + healthPort }; } diff --git a/status/js/app-selector.js b/status/js/app-selector.js index 60c7c4a..aafb3aa 100644 --- a/status/js/app-selector.js +++ b/status/js/app-selector.js @@ -10,6 +10,22 @@ `); + injectModal('dep-warning-modal', `
+
+

Missing Dependencies

+ +
+

+ Recommended: deploy these dependencies first so the app works correctly on first launch. +

+
+ + + +
+
+
`); + injectModal('app-deploy-modal', `

Deploy Application

@@ -338,7 +354,7 @@ if (isWidget) { option.onclick = () => toggleDashboardWidget(app, option); } else { - option.onclick = () => showDeployConfig(app); + option.onclick = () => checkAndShowDeployConfig(app); } grid.appendChild(option); }); @@ -375,6 +391,93 @@ showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000); } + // Check declared dependencies for an app before opening the deploy modal. + // If any are missing, show the dep-warning-modal so the user can pick which to auto-deploy. + async function checkAndShowDeployConfig(appTemplate) { + // Reset any leftover dep selection from a previous flow + delete appTemplate._deployDeps; + + let depResult = null; + try { + const resp = await secureFetch('/api/v1/apps/check-dependencies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId: appTemplate.id }) + }); + depResult = await resp.json(); + } catch (e) { + // Network/server error — fall through to normal deploy flow + console.warn('Dep check failed, continuing without it:', e); + return showDeployConfig(appTemplate); + } + + // No deps declared, or all already deployed + if (!depResult || !Array.isArray(depResult.missing) || depResult.missing.length === 0) { + return showDeployConfig(appTemplate); + } + + // Show warning modal + const depModal = document.getElementById('dep-warning-modal'); + const titleEl = document.getElementById('dep-warning-title'); + const subtitleEl = document.getElementById('dep-warning-subtitle'); + const listEl = document.getElementById('dep-warning-list'); + const cancelBtn = document.getElementById('dep-warning-cancel'); + const skipBtn = document.getElementById('dep-warning-skip'); + const confirmBtn = document.getElementById('dep-warning-confirm'); + + titleEl.textContent = `${appTemplate.name} has missing dependencies`; + subtitleEl.textContent = `${appTemplate.name} works best when these apps are deployed first:`; + + // Render checkboxes for each missing dep + listEl.innerHTML = depResult.missingDetails.map(d => ` + + `).join(''); + + // Close the app selector behind it + modal.classList.remove('show'); + depModal.classList.add('show'); + + // Helper to clean up event listeners after one of the buttons is pressed + const cleanup = () => { + cancelBtn.onclick = null; + skipBtn.onclick = null; + confirmBtn.onclick = null; + }; + + cancelBtn.onclick = () => { + cleanup(); + depModal.classList.remove('show'); + // Re-open app selector so user can pick something else + modal.classList.add('show'); + }; + + skipBtn.onclick = () => { + cleanup(); + depModal.classList.remove('show'); + // Continue to normal deploy flow without any deps + showDeployConfig(appTemplate); + }; + + confirmBtn.onclick = () => { + // Collect checked deps + const chosen = Array.from(listEl.querySelectorAll('.dep-warning-checkbox')) + .filter(cb => cb.checked) + .map(cb => cb.value); + cleanup(); + depModal.classList.remove('show'); + // Stash chosen deps on the template; addAppToGrid will pick them up + if (chosen.length > 0) appTemplate._deployDeps = chosen; + showDeployConfig(appTemplate); + }; + } + // Show deployment configuration modal async function showDeployConfig(appTemplate) { const deployModal = document.getElementById('app-deploy-modal'); @@ -765,6 +868,11 @@ } }; + // Include user-selected dependencies (from dep warning modal) + if (Array.isArray(appTemplate._deployDeps) && appTemplate._deployDeps.length > 0) { + apiDeployConfig.deployDependencies = appTemplate._deployDeps; + } + // Add existing container info if using existing if (usingExisting) { apiDeployConfig.config.useExisting = true;