Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
591 lines
23 KiB
JavaScript
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)} • 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();
|
|
})();
|