Files
dashcaddy/status/js/recipes.js
Sami f61e85d9a7 Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
2026-03-05 02:26:12 -08:00

591 lines
23 KiB
JavaScript

// Recipe System — multi-container stack deployment
(function() {
// === RECIPE DEPLOY WIZARD MODAL ===
injectModal('recipe-deploy-modal', `<div id="recipe-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
<!-- Step indicator -->
<div id="recipe-steps" style="display: flex; gap: 4px; margin: 16px 0 24px;">
<div class="recipe-step active" data-step="1"><span>1</span> Components</div>
<div class="recipe-step" data-step="2"><span>2</span> Configuration</div>
<div class="recipe-step" data-step="3"><span>3</span> Review</div>
<div class="recipe-step" data-step="4"><span>4</span> Progress</div>
</div>
<!-- Step 1: Component Selection -->
<div id="recipe-step-1" class="recipe-step-panel">
<label class="form-label-accent-sm">Select Components:</label>
<div id="recipe-component-list" style="display: grid; gap: 10px; margin-top: 8px;"></div>
</div>
<!-- Step 2: Shared Configuration -->
<div id="recipe-step-2" class="recipe-step-panel" style="display:none;">
<div style="display: grid; gap: 16px;">
<!-- Shared volumes -->
<div id="recipe-volumes-section" style="display:none;">
<label class="form-label-accent-sm">Shared Volumes:</label>
<div id="recipe-volume-list" style="display: grid; gap: 10px; margin-top: 8px;"></div>
</div>
<!-- Timezone -->
<div>
<label class="form-label-accent-sm">Timezone:</label>
<input type="text" id="recipe-timezone" value="UTC" placeholder="e.g. America/New_York"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
</div>
<!-- Target IP -->
<div>
<label class="form-label-accent-sm">Target IP Address:</label>
<input type="text" id="recipe-ip" value="host.docker.internal" placeholder="localhost or IP"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
<div class="form-hint-sm">IP where containers expose ports</div>
</div>
<!-- Tailscale -->
<label class="checkbox-label">
<input type="checkbox" id="recipe-tailscale" />
<span>Restrict to Tailscale network only</span>
</label>
</div>
</div>
<!-- Step 3: Review -->
<div id="recipe-step-3" class="recipe-step-panel" style="display:none;">
<div id="recipe-review-content" style="font-size: 0.9rem; line-height: 1.7;"></div>
</div>
<!-- Step 4: Progress -->
<div id="recipe-step-4" class="recipe-step-panel" style="display:none;">
<div id="recipe-progress-list" style="display: grid; gap: 8px;"></div>
<div id="recipe-deploy-result" style="margin-top: 16px; display:none;"></div>
</div>
<div class="weather-modal-buttons" style="margin-top: 20px;">
<button id="recipe-cancel">Cancel</button>
<button id="recipe-prev" class="btn-accent" style="display:none;">Back</button>
<button id="recipe-next" class="btn-accent-solid">Next</button>
</div>
</div>
</div>`);
// === 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 = `<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${recipe.componentCount || recipe.components?.length || '?'} apps</div>`;
const lockOverlay = !isPremium ? `<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>` : '';
option.innerHTML = `
${lockOverlay}
<div class="app-option-icon">${escapeHtml(recipe.icon || '\uD83E\uDDEA')}</div>
<div class="app-option-name">${escapeHtml(recipe.name)}</div>
<div class="app-option-desc">${escapeHtml(recipe.description || '')}</div>
${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 = `
<input type="checkbox" ${isRequired ? 'checked disabled' : 'checked'} data-component-id="${comp.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(comp.role || comp.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);">
${comp.templateRef ? escapeHtml(comp.templateRef) : 'Built-in'}
${isRequired ? '<span style="color: var(--accent); margin-left: 6px;">Required</span>' : '<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${isInternal ? '<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>' : ''}
</div>
${comp.note ? `<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(comp.note)}</div>` : ''}
</div>
`;
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 = `
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(vol.label || key)}</label>
<input type="text" data-volume-key="${key}" value="${escapeHtml(vol.defaultPath || '')}"
placeholder="${escapeHtml(vol.defaultPath || '/path')}"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
<div class="form-hint-sm">${escapeHtml(vol.description || '')}</div>
`;
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 = `
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(currentRecipe.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(currentRecipe.description || '')}</div>
<div style="margin-bottom: 12px;">
<strong>Components (${selectedComponents.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;">
${selectedComponents.map(c => `<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(c.role || c.id)}</strong> ${c.internal ? '<span style="color:var(--muted)">(internal)</span>' : ''}
</div>`).join('')}
</div>
</div>
${Object.keys(volumes).length > 0 ? `<div style="margin-bottom: 12px;">
<strong>Volumes:</strong>
${Object.entries(volumes).map(([k, v]) => `<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${k}: ${escapeHtml(v)}</div>`).join('')}
</div>` : ''}
<div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(tz)} &bull; IP: ${escapeHtml(ip)} ${tailscale ? '&bull; Tailscale only' : ''}
</div>
${currentRecipe.network ? `<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(currentRecipe.network.name)}</code></div>` : ''}
`;
}
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 = `
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(comp.role || comp.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`;
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 = `
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(data.message || 'Deployed!')}</div>
${data.setupInstructions ? `<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${data.setupInstructions.map(s => `<li>${escapeHtml(s)}</li>`).join('')}</ul>
</div>` : ''}
</div>
`;
showNotification(`${currentRecipe.name} recipe deployed successfully!`, 'success', 5000);
// Refresh dashboard
if (window.loadServices) window.loadServices();
} else {
resultEl.style.display = '';
resultEl.innerHTML = `<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(data.error || 'Unknown error')}
</div>`;
showNotification(`Recipe deployment failed: ${data.error}`, 'error', 5000);
}
} catch (e) {
resultEl.style.display = '';
resultEl.innerHTML = `<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(e.message)}
</div>`;
}
}
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();
})();