Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
590
status/js/recipes.js
Normal file
590
status/js/recipes.js
Normal file
@@ -0,0 +1,590 @@
|
||||
// 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)} • IP: ${escapeHtml(ip)} ${tailscale ? '• 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user