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:
429
status/js/core/service-crud.js
Normal file
429
status/js/core/service-crud.js
Normal file
@@ -0,0 +1,429 @@
|
||||
// ========== 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 = `https://${buildDomain(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.<br>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;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user