Apps can now be served at domain.com/appname/ instead of requiring subdomain DNS records (appname.domain.com). Supports three subpath modes per template: native (URL base env var), strip (handle_path), and none (incompatible warning). Tested on Linux with deploy/removal lifecycle verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
430 lines
15 KiB
JavaScript
430 lines
15 KiB
JavaScript
// ========== 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.<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;
|
|
|
|
})();
|