// Recipe System — multi-container stack deployment (function() { // === RECIPE DEPLOY WIZARD MODAL === injectModal('recipe-deploy-modal', `

Deploy Recipe

1 Components
2 Configuration
3 Review
4 Progress
`); // === STATE === let recipeTemplates = null; let recipeCategories = null; let currentRecipe = null; let currentStep = 1; let isPremium = false; const deployModal = document.getElementById('recipe-deploy-modal'); const cancelBtn = document.getElementById('recipe-cancel'); const prevBtn = document.getElementById('recipe-prev'); const nextBtn = document.getElementById('recipe-next'); wireModal(deployModal, cancelBtn); // === FETCH RECIPE TEMPLATES === async function fetchRecipeTemplates() { try { const resp = await fetch('/api/v1/recipes/templates'); const data = await resp.json(); if (data.success) { recipeTemplates = data.templates; recipeCategories = data.categories; return true; } // Premium required — templates API gated if (resp.status === 403) { isPremium = false; return false; } } catch (e) { console.warn('Failed to fetch recipe templates:', e.message); } return false; } // === CHECK PREMIUM === async function checkRecipePremium() { try { const resp = await fetch('/api/v1/license/feature/recipes'); const data = await resp.json(); isPremium = data.available; } catch { isPremium = false; } return isPremium; } // === RENDER RECIPE CARDS INTO APP SELECTOR === // This function is called by the app selector to inject recipe cards window.renderRecipeCards = async function(grid) { await checkRecipePremium(); // Build recipe data — we can show card metadata even without premium // (cards just show a lock and prompt to upgrade) let recipes; if (isPremium && recipeTemplates) { recipes = recipeTemplates; } else { // Show hardcoded preview cards when not premium recipes = getPreviewRecipes(); } if (!recipes || recipes.length === 0) return; // Category header const header = document.createElement('div'); header.className = 'app-category-header'; header.innerHTML = `\uD83E\uDDEA Recipes`; header.style.borderBottomColor = '#8e44ad'; grid.appendChild(header); const recipeList = Array.isArray(recipes) ? recipes : Object.values(recipes); recipeList.sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); for (const recipe of recipeList) { const option = document.createElement('div'); option.className = 'app-option'; option.style.position = 'relative'; const componentBadge = `
${recipe.componentCount || recipe.components?.length || '?'} apps
`; const lockOverlay = !isPremium ? `
PREMIUM
` : ''; option.innerHTML = ` ${lockOverlay}
${escapeHtml(recipe.icon || '\uD83E\uDDEA')}
${escapeHtml(recipe.name)}
${escapeHtml(recipe.description || '')}
${componentBadge} `; option.onclick = () => { if (!isPremium) { showNotification('Recipes require a DashCaddy Premium license. Click the License button to activate.', 'warning', 5000); if (window.openLicenseModal) window.openLicenseModal(); return; } openRecipeDeployWizard(recipe); }; grid.appendChild(option); } }; function getPreviewRecipes() { return [ { id: 'htpc-suite', name: 'HTPC Suite', icon: '\uD83C\uDFAC', description: 'Complete media automation: find, download, organize, and stream', componentCount: 6, popularity: 98 }, { id: 'nextcloud-complete', name: 'Nextcloud Complete', icon: '\u2601\uFE0F', description: 'Full productivity suite: cloud storage, office editing, and collaboration', componentCount: 4, popularity: 90 }, { id: 'smart-home', name: 'Smart Home Hub', icon: '\uD83C\uDFE0', description: 'Home automation: control, automate, and monitor IoT devices', componentCount: 4, popularity: 88 }, { id: 'dev-environment', name: 'Dev Environment', icon: '\uD83D\uDCBB', description: 'Self-hosted development workflow: Git, CI/CD, IDE, and database', componentCount: 4, popularity: 82 } ]; } // === RECIPE DEPLOY WIZARD === function openRecipeDeployWizard(recipe) { currentRecipe = recipe; currentStep = 1; // Close app selector const appSelectorModal = document.getElementById('app-selector-modal'); if (appSelectorModal) appSelectorModal.classList.remove('show'); document.getElementById('recipe-deploy-title').textContent = `Deploy ${recipe.name}`; // Reset steps updateStepUI(); renderStep1(); deployModal.classList.add('show'); } function updateStepUI() { // Step indicators document.querySelectorAll('#recipe-steps .recipe-step').forEach(el => { const step = parseInt(el.dataset.step); el.classList.toggle('active', step === currentStep); el.classList.toggle('completed', step < currentStep); }); // Step panels for (let i = 1; i <= 4; i++) { const panel = document.getElementById(`recipe-step-${i}`); if (panel) panel.style.display = i === currentStep ? '' : 'none'; } // Navigation buttons prevBtn.style.display = currentStep > 1 && currentStep < 4 ? '' : 'none'; if (currentStep === 4) { nextBtn.style.display = 'none'; cancelBtn.textContent = 'Close'; } else if (currentStep === 3) { nextBtn.textContent = '\uD83D\uDE80 Deploy'; nextBtn.style.display = ''; cancelBtn.textContent = 'Cancel'; } else { nextBtn.textContent = 'Next'; nextBtn.style.display = ''; cancelBtn.textContent = 'Cancel'; } } // Step 1: Component selection function renderStep1() { const list = document.getElementById('recipe-component-list'); list.innerHTML = ''; const components = currentRecipe.components || []; for (const comp of components) { const el = document.createElement('div'); el.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);'; const isRequired = comp.required; const isInternal = comp.internal; el.innerHTML = `
${escapeHtml(comp.role || comp.id)}
${comp.templateRef ? escapeHtml(comp.templateRef) : 'Built-in'} ${isRequired ? 'Required' : 'Optional'} ${isInternal ? '(Internal)' : ''}
${comp.note ? `
\u26A0 ${escapeHtml(comp.note)}
` : ''}
`; list.appendChild(el); } } // Step 2: Shared configuration function renderStep2() { const volumeSection = document.getElementById('recipe-volumes-section'); const volumeList = document.getElementById('recipe-volume-list'); const sharedVolumes = currentRecipe.sharedVolumes; if (sharedVolumes && Object.keys(sharedVolumes).length > 0) { volumeSection.style.display = ''; volumeList.innerHTML = ''; for (const [key, vol] of Object.entries(sharedVolumes)) { const el = document.createElement('div'); el.style.cssText = 'display: grid; gap: 4px;'; el.innerHTML = `
${escapeHtml(vol.description || '')}
`; volumeList.appendChild(el); } } else { volumeSection.style.display = 'none'; } } // Step 3: Review function renderStep3() { const content = document.getElementById('recipe-review-content'); const selectedComponents = getSelectedComponents(); const volumeInputs = document.querySelectorAll('#recipe-volume-list input[data-volume-key]'); const volumes = {}; volumeInputs.forEach(input => { volumes[input.dataset.volumeKey] = input.value; }); const tz = document.getElementById('recipe-timezone').value || 'UTC'; const ip = document.getElementById('recipe-ip').value || 'host.docker.internal'; const tailscale = document.getElementById('recipe-tailscale').checked; content.innerHTML = `
${escapeHtml(currentRecipe.name)}
${escapeHtml(currentRecipe.description || '')}
Components (${selectedComponents.length}):
${selectedComponents.map(c => `
\u2022 ${escapeHtml(c.role || c.id)} ${c.internal ? '(internal)' : ''}
`).join('')}
${Object.keys(volumes).length > 0 ? `
Volumes: ${Object.entries(volumes).map(([k, v]) => `
${k}: ${escapeHtml(v)}
`).join('')}
` : ''}
Timezone: ${escapeHtml(tz)} • IP: ${escapeHtml(ip)} ${tailscale ? '• Tailscale only' : ''}
${currentRecipe.network ? `
Docker network: ${escapeHtml(currentRecipe.network.name)}
` : ''} `; } function getSelectedComponents() { const checkboxes = document.querySelectorAll('#recipe-component-list input[data-component-id]'); const selectedIds = new Set(); checkboxes.forEach(cb => { if (cb.checked) selectedIds.add(cb.dataset.componentId); }); // Always include required components const components = currentRecipe.components || []; components.filter(c => c.required).forEach(c => selectedIds.add(c.id)); return components.filter(c => selectedIds.has(c.id)); } // Step 4: Deploy async function executeDeploy() { const progressList = document.getElementById('recipe-progress-list'); const resultEl = document.getElementById('recipe-deploy-result'); resultEl.style.display = 'none'; progressList.innerHTML = ''; const selectedComponents = getSelectedComponents(); // Show progress items for (const comp of selectedComponents) { const el = document.createElement('div'); el.id = `recipe-progress-${comp.id}`; el.style.cssText = 'display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;'; el.innerHTML = ` \u23F3 ${escapeHtml(comp.role || comp.id)} Queued `; progressList.appendChild(el); } // Collect config const volumeInputs = document.querySelectorAll('#recipe-volume-list input[data-volume-key]'); const volumes = {}; volumeInputs.forEach(input => { volumes[input.dataset.volumeKey] = input.value; }); const config = { selectedComponents: selectedComponents.map(c => c.id), sharedConfig: { ip: document.getElementById('recipe-ip').value || 'host.docker.internal', timezone: document.getElementById('recipe-timezone').value || 'UTC', tailscaleOnly: document.getElementById('recipe-tailscale').checked, volumes }, componentOverrides: {} }; // Mark all as deploying for (const comp of selectedComponents) { updateProgressItem(comp.id, 'deploying', 'Deploying...'); } try { const resp = await secureFetch('/api/v1/recipes/deploy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipeId: currentRecipe.id, config }) }); const data = await resp.json(); if (data.success) { // Mark deployed components for (const deployed of (data.deployed || [])) { updateProgressItem(deployed.id, 'success', deployed.url ? `Running \u2192 ${deployed.url}` : 'Running'); } // Mark errors for (const err of (data.errors || [])) { updateProgressItem(err.componentId, 'error', err.error); } resultEl.style.display = ''; resultEl.innerHTML = `
${escapeHtml(data.message || 'Deployed!')}
${data.setupInstructions ? `
Setup tips:
` : ''}
`; showNotification(`${currentRecipe.name} recipe deployed successfully!`, 'success', 5000); // Refresh dashboard if (window.loadServices) window.loadServices(); } else { resultEl.style.display = ''; resultEl.innerHTML = `
Deployment failed: ${escapeHtml(data.error || 'Unknown error')}
`; showNotification(`Recipe deployment failed: ${data.error}`, 'error', 5000); } } catch (e) { resultEl.style.display = ''; resultEl.innerHTML = `
Network error: ${escapeHtml(e.message)}
`; } } function updateProgressItem(componentId, status, text) { const el = document.getElementById(`recipe-progress-${componentId}`); if (!el) return; const icon = el.querySelector('.recipe-progress-icon'); const statusEl = el.querySelector('.recipe-progress-status'); if (status === 'deploying') { icon.textContent = '\u23F3'; statusEl.style.color = 'var(--accent)'; } else if (status === 'success') { icon.textContent = '\u2705'; statusEl.style.color = 'var(--ok-fg)'; } else if (status === 'error') { icon.textContent = '\u274C'; statusEl.style.color = 'var(--bad-fg)'; } statusEl.textContent = text; } // === STEP NAVIGATION === nextBtn.addEventListener('click', () => { if (currentStep === 3) { currentStep = 4; updateStepUI(); executeDeploy(); return; } if (currentStep < 3) { currentStep++; updateStepUI(); if (currentStep === 2) renderStep2(); if (currentStep === 3) renderStep3(); } }); prevBtn.addEventListener('click', () => { if (currentStep > 1 && currentStep < 4) { currentStep--; updateStepUI(); } }); // === RECIPE CARD GROUPING ON DASHBOARD === // After dashboard loads, group cards that share a recipeId window.groupRecipeCards = function() { const cards = document.querySelectorAll('.service-card[data-recipe-id]'); if (cards.length === 0) return; const groups = {}; cards.forEach(card => { const recipeId = card.dataset.recipeId; if (!groups[recipeId]) groups[recipeId] = []; groups[recipeId].push(card); }); for (const [recipeId, groupCards] of Object.entries(groups)) { if (groupCards.length < 2) continue; // Apply subtle visual grouping groupCards.forEach((card, i) => { card.style.borderLeft = '3px solid rgba(142,68,173,0.5)'; if (i === 0) { // Add a recipe label to the first card let label = card.querySelector('.recipe-group-label'); if (!label) { label = document.createElement('div'); label.className = 'recipe-group-label'; label.style.cssText = 'position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;'; label.textContent = recipeId.replace(/-/g, ' '); card.style.position = 'relative'; card.appendChild(label); } } }); } }; // === RECIPE MANAGEMENT ACTIONS === window.manageRecipe = async function(recipeId, action) { const endpoint = `/api/v1/recipes/${recipeId}/${action}`; const method = action === 'remove' ? 'DELETE' : 'POST'; const url = action === 'remove' ? `/api/v1/recipes/${recipeId}` : endpoint; if (action === 'remove' && !confirm(`Remove the entire ${recipeId} recipe? This will delete all containers and configuration.`)) { return; } try { const resp = await secureFetch(url, { method }); const data = await resp.json(); if (data.success) { showNotification(`Recipe ${action}: ${data.results?.filter(r => r.status !== 'failed').length || 0} components processed`, 'success', 4000); if (window.loadServices) window.loadServices(); } else { showNotification(`Recipe ${action} failed: ${data.error}`, 'error', 5000); } } catch (e) { showNotification(`Network error: ${e.message}`, 'error', 5000); } }; // === INJECT CSS === const style = document.createElement('style'); style.textContent = ` .recipe-step { flex: 1; text-align: center; padding: 8px 4px; font-size: 0.78rem; color: var(--muted); border-bottom: 2px solid var(--border); transition: all 0.2s; } .recipe-step span { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: var(--border); color: var(--muted); font-size: 0.7rem; font-weight: 700; margin-right: 4px; } .recipe-step.active { color: var(--accent); border-bottom-color: var(--accent); } .recipe-step.active span { background: var(--accent); color: #fff; } .recipe-step.completed { color: var(--ok-fg); border-bottom-color: var(--ok-fg); } .recipe-step.completed span { background: var(--ok-fg); color: #fff; } .recipe-step-panel { min-height: 180px; } `; document.head.appendChild(style); // === INIT === // Pre-fetch premium status on load checkRecipePremium(); })();