// ========== SERVICE CRUD ========== // Edit, delete, and update operations for existing services. (function () { // ===== SERVICE EDIT MODAL ===== let currentEditService = null; function openServiceEditModal(service) { currentEditService = service; const modal = document.getElementById('service-edit-modal'); document.getElementById('service-edit-title').textContent = `Edit ${service.name}`; document.getElementById('edit-service-name-display').textContent = service.name; document.getElementById('edit-service-url-display').textContent = service.url || buildServiceUrl(service.id); document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`; document.getElementById('edit-subdomain').value = service.id; document.getElementById('edit-port').value = service.port || ''; document.getElementById('edit-ip').value = service.ip || 'localhost'; document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false; document.getElementById('edit-logo-url').value = service.logo || ''; modal.classList.add('show'); } function closeServiceEditModal() { closeModal('service-edit-modal'); currentEditService = null; } async function saveServiceChanges() { if (!currentEditService) return; const newSubdomain = document.getElementById('edit-subdomain').value.trim().toLowerCase(); const newPort = document.getElementById('edit-port').value.trim(); const newIp = document.getElementById('edit-ip').value.trim() || 'localhost'; const tailscaleOnly = document.getElementById('edit-tailscale-only').checked; const newLogo = document.getElementById('edit-logo-url').value.trim(); if (!newSubdomain) { showNotification('Subdomain is required', 'warning'); return; } const oldSubdomain = currentEditService.id; const changes = []; if (newSubdomain !== oldSubdomain) changes.push('subdomain'); if (newPort && newPort !== String(currentEditService.port)) changes.push('port'); if (newIp !== currentEditService.ip) changes.push('ip'); if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale'); if (newLogo !== currentEditService.logo) changes.push('logo'); if (changes.length === 0) { closeServiceEditModal(); return; } const saveBtn = document.getElementById('service-edit-save'); saveBtn.textContent = 'Saving...'; saveBtn.disabled = true; try { if (changes.includes('subdomain') || changes.includes('port') || changes.includes('ip') || changes.includes('tailscale')) { const response = await secureFetch('/api/v1/services/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldSubdomain, newSubdomain, port: newPort || currentEditService.port, ip: newIp, tailscaleOnly }) }); const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to update service'); } } // Update local APPS array const appIndex = window.APPS.findIndex(a => a.id === oldSubdomain); if (appIndex !== -1) { window.APPS[appIndex] = { ...window.APPS[appIndex], id: newSubdomain, port: newPort || window.APPS[appIndex].port, ip: newIp, tailscaleOnly, logo: newLogo || window.APPS[appIndex].logo }; } // Update services via API await secureFetch('/api/v1/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: newSubdomain, name: currentEditService.name, port: newPort || currentEditService.port, ip: newIp, logo: newLogo || currentEditService.logo, tailscaleOnly, containerId: currentEditService.containerId, appTemplate: currentEditService.appTemplate }) }); // If subdomain changed, remove old entry if (newSubdomain !== oldSubdomain) { await secureFetch(`/api/v1/services/${oldSubdomain}`, { method: 'DELETE' }); } closeServiceEditModal(); window.buildGrid(); window.refreshAll(); } catch (error) { console.error('Error saving service changes:', error); showNotification(`Error saving changes: ${error.message}`, 'error'); } finally { saveBtn.textContent = 'Save Changes'; saveBtn.disabled = false; } } // Logo file upload handler document.getElementById('edit-logo-file')?.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; if (!file.type.startsWith('image/')) { showNotification('Please select an image file', 'warning'); return; } const reader = new FileReader(); reader.onload = async (event) => { const dataUrl = event.target.result; document.getElementById('edit-service-logo-preview').src = dataUrl; document.getElementById('edit-logo-url').value = dataUrl; if (currentEditService) { try { const response = await secureFetch('/api/v1/assets/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: `${currentEditService.id}.png`, data: dataUrl }) }); const result = await response.json(); if (result.success && result.path) { document.getElementById('edit-logo-url').value = result.path; } } catch (err) { // Fallback to data URL } } }; reader.readAsDataURL(file); }); // Service edit modal event listeners document.getElementById('service-edit-cancel')?.addEventListener('click', closeServiceEditModal); document.getElementById('service-edit-save')?.addEventListener('click', saveServiceChanges); document.getElementById('service-edit-modal')?.addEventListener('click', (e) => { if (e.target.id === 'service-edit-modal') closeServiceEditModal(); }); // ===== DELETE SERVICE MODAL ===== function showDeleteModal(serviceName, hasContainer, containerId) { return new Promise((resolve) => { const modal = document.getElementById('delete-service-modal'); const title = document.getElementById('delete-modal-title'); const message = document.getElementById('delete-modal-message'); const containerInfo = document.getElementById('delete-modal-container-info'); const containerName = document.getElementById('delete-modal-container-name'); const help = document.getElementById('delete-modal-help'); const cancelBtn = document.getElementById('delete-modal-cancel'); const removeBtn = document.getElementById('delete-modal-remove'); const deleteBtn = document.getElementById('delete-modal-delete'); title.textContent = `Delete "${serviceName}"`; if (hasContainer) { message.innerHTML = 'This service has an associated Docker container.
Choose how to proceed:'; containerInfo.style.display = 'block'; containerName.textContent = `Container ID: ${containerId?.slice(0, 12) || 'Unknown'}`; help.style.display = 'block'; deleteBtn.style.display = 'block'; } else { message.textContent = 'Remove this service from the dashboard?'; containerInfo.style.display = 'none'; help.style.display = 'none'; deleteBtn.style.display = 'none'; } const cleanup = () => { modal.classList.remove('show'); cancelBtn.removeEventListener('click', handleCancel); removeBtn.removeEventListener('click', handleRemove); deleteBtn.removeEventListener('click', handleDelete); modal.removeEventListener('click', handleBackdrop); }; const handleCancel = () => { cleanup(); resolve(null); }; const handleRemove = () => { cleanup(); resolve(false); }; const handleDelete = () => { cleanup(); resolve(true); }; const handleBackdrop = (e) => { if (e.target === modal) { cleanup(); resolve(null); } }; cancelBtn.addEventListener('click', handleCancel); removeBtn.addEventListener('click', handleRemove); deleteBtn.addEventListener('click', handleDelete); modal.addEventListener('click', handleBackdrop); modal.classList.add('show'); }); } // ===== UPDATE CONTAINER ===== async function updateContainer(containerId, serviceName, serviceId) { const updateBtn = document.getElementById(`update-btn-${serviceId}`); const originalText = updateBtn?.textContent; if (!confirm(`Update ${serviceName} to the latest version?\n\nThis will:\n1. Pull the latest image\n2. Stop the container\n3. Recreate with same settings\n\nThe service will be briefly unavailable.`)) { return; } try { if (updateBtn) { updateBtn.textContent = '\u{1F504}'; updateBtn.disabled = true; updateBtn.title = 'Updating...'; } const response = await secureFetch(`/api/v1/containers/${containerId}/update`, { method: 'POST' }); const result = await response.json(); if (result.success) { const service = window.APPS.find(app => app.id === serviceId); if (service && result.newContainerId) { service.containerId = result.newContainerId; } if (updateBtn) { updateBtn.textContent = '\u{2705}'; updateBtn.title = 'Updated successfully!'; setTimeout(() => { updateBtn.textContent = originalText; updateBtn.disabled = false; updateBtn.title = 'Update container to latest version'; }, 3000); } setTimeout(() => window.refreshAll(), 2000); showNotification(`${serviceName} updated successfully!`, 'success'); } else { throw new Error(result.error || 'Update failed'); } } catch (error) { console.error('Update error:', error); if (updateBtn) { updateBtn.textContent = '\u{274C}'; updateBtn.title = 'Update failed'; setTimeout(() => { updateBtn.textContent = originalText; updateBtn.disabled = false; updateBtn.title = 'Update container to latest version'; }, 3000); } showNotification(`Failed to update ${serviceName}: ${error.message}`, 'error'); } } // ===== DELETE SERVICE ===== async function deleteService(serviceId, serviceName) { const service = window.APPS.find(app => app.id === serviceId); const domain = service ? buildDomain(service.id) : null; const hasContainer = service?.containerId; const deleteContainer = await showDeleteModal(serviceName || serviceId, hasContainer, service?.containerId); if (deleteContainer === null) { return; // User cancelled } let results = { dashboard: false, container: null, dns: null, caddy: null, service: null }; // Full removal with container if (deleteContainer && hasContainer) { try { const params = new URLSearchParams({ containerId: service.containerId, subdomain: service.id, ip: service.ip || 'localhost', deleteContainer: 'true' }); const response = await secureFetch(`/api/v1/apps/${encodeURIComponent(service.id)}?${params.toString()}`, { method: 'DELETE' }); const result = await response.json(); if (result.success) { results = { ...results, ...result.results, dashboard: false }; } else { console.error('App removal failed:', result.error); } } catch (error) { console.error('App removal error:', error); } } else if (deleteContainer && domain) { // Fallback for manually added services try { const serviceIP = service?.ip || 'localhost'; const dnsResponse = await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(domain)}&type=A&ipAddress=${encodeURIComponent(serviceIP)}&server=${SITE.dnsIp}`, { method: 'DELETE' }); const dnsResult = await dnsResponse.json(); results.dns = dnsResult.success ? 'deleted' : (dnsResult.error || 'failed'); } catch (e) { results.dns = e.message; } try { const caddyResponse = await secureFetch(`/api/v1/site/${encodeURIComponent(domain)}`, { method: 'DELETE' }); const caddyResult = await caddyResponse.json(); results.caddy = (caddyResult.success || (caddyResult.error && caddyResult.error.includes('not found'))) ? 'removed' : (caddyResult.error || 'failed'); } catch (e) { results.caddy = e.message; } } // Remove from APPS array const index = window.APPS.findIndex(app => app.id === serviceId); if (index > -1) { window.APPS.splice(index, 1); results.dashboard = true; } // Remove from localStorage try { const customApps = safeGetJSON('custom-apps', []); const localIndex = customApps.findIndex(app => app.id === serviceId); if (localIndex > -1) { customApps.splice(localIndex, 1); safeSet('custom-apps', JSON.stringify(customApps)); } } catch (e) { // Ignore localStorage errors } // Remove from services.json via API try { const serviceResponse = await secureFetch(`/api/v1/services/${encodeURIComponent(serviceId)}`, { method: 'DELETE' }); const serviceResult = await serviceResponse.json(); results.service = serviceResult.success ? 'removed' : (serviceResult.error || 'failed'); } catch (e) { results.service = e.message; } window.buildGrid(); window.refreshAll(); // Only show alert if there are actual errors let hasErrors = false; let errorMessages = []; if (!results.dashboard) { hasErrors = true; errorMessages.push('\u{2717} Failed to remove from dashboard'); } const successStates = ['removed', 'already removed', 'not found', 'deleted', 'kept (user choice)', 'skipped', 'no such record', 'does not exist']; const isSuccess = (val) => !val || successStates.some(s => val.toLowerCase().includes(s.toLowerCase())); if (results.container && !isSuccess(results.container)) { hasErrors = true; errorMessages.push(`\u{26A0} Container: ${results.container}`); } if (results.dns && !isSuccess(results.dns)) { hasErrors = true; errorMessages.push(`\u{26A0} DNS Record: ${results.dns}`); } if (results.caddy && !isSuccess(results.caddy)) { hasErrors = true; errorMessages.push(`\u{26A0} Caddy Config: ${results.caddy}`); } if (results.service && !isSuccess(results.service)) { hasErrors = true; errorMessages.push(`\u{26A0} Service File: ${results.service}`); } if (hasErrors) { showNotification(`Error deleting "${serviceName || serviceId}": ${errorMessages.join(', ')}`, 'error', 6000); } } // ===== WINDOW EXPORTS ===== window.openServiceEditModal = openServiceEditModal; window.showDeleteModal = showDeleteModal; window.updateContainer = updateContainer; window.deleteService = deleteService; })();