Files
dashcaddy/status/js/core/service-create.js
Sami 77030931b7 Add subdirectory routing mode for public domain deployments
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>
2026-03-06 03:03:17 -08:00

649 lines
25 KiB
JavaScript

// ========== SERVICE CREATION ==========
// Add service modal, local/external service creation flows, and event wiring.
(function () {
// ===== SUBDOMAIN AUTO-DERIVE =====
function deriveSubdomain(name) {
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function getSmartSslDefault() {
return SITE.defaults?.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'caddy-managed');
}
// ===== SERVICE PREVIEW =====
function updateServicePreview() {
const subdomain = document.getElementById('service-subdomain-input').value || 'subdomain';
const ip = document.getElementById('service-ip-input').value || QUICK_IPS.lan || 'localhost';
const port = document.getElementById('service-port-input').value || DC.DEFAULTS.SERVICE_PORT;
const sslType = document.getElementById('ssl-type-select').value;
const caName = document.getElementById('ca-name-input').value || 'sami-ca';
const existingCa = document.getElementById('existing-ca-select').value;
const enableAuth = document.getElementById('enable-auth').checked;
const enableCors = document.getElementById('enable-cors').checked;
const customHeaders = document.getElementById('custom-headers-input').value;
const upstreamPath = document.getElementById('upstream-path-input').value || '/';
const healthCheck = document.getElementById('health-check-input').value;
const timeout = document.getElementById('timeout-input').value || 30;
const dnsPreview = document.getElementById('dns-preview');
if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`;
const urlPreview = document.getElementById('url-preview');
if (urlPreview) urlPreview.textContent = buildServiceUrl(subdomain);
const config = {
subdomain, port, ip, sslType, caName, existingCa,
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout
};
const caddyConfig = window.generateCaddyConfig(config);
const configPreview = document.getElementById('caddy-config-preview');
if (configPreview) configPreview.value = caddyConfig;
}
// ===== QUICK IP CONFIGURATION =====
const QUICK_IPS = {
localhost: '127.0.0.1',
lan: '',
tailscale: ''
};
async function detectNetworkIPs() {
try {
const response = await fetch('/api/v1/network/ips', {
signal: AbortSignal.timeout(2000)
});
if (response.ok) {
const data = await response.json();
if (data.lan) QUICK_IPS.lan = data.lan;
if (data.tailscale) QUICK_IPS.tailscale = data.tailscale;
}
} catch (e) {
// API not available
}
const lanBtn = document.getElementById('quick-ip-lan');
const tsBtn = document.getElementById('quick-ip-tailscale');
if (lanBtn) {
if (QUICK_IPS.lan) {
lanBtn.dataset.ip = QUICK_IPS.lan;
lanBtn.textContent = `LAN (${QUICK_IPS.lan})`;
lanBtn.title = `LAN IP: ${QUICK_IPS.lan}`;
} else {
lanBtn.style.display = 'none';
}
}
if (tsBtn) {
if (QUICK_IPS.tailscale) {
tsBtn.dataset.ip = QUICK_IPS.tailscale;
tsBtn.textContent = `Tailscale (${QUICK_IPS.tailscale})`;
tsBtn.title = `Tailscale IP: ${QUICK_IPS.tailscale}`;
} else {
tsBtn.style.display = 'none';
}
}
const ipInput = document.getElementById('service-ip-input');
if (ipInput && !ipInput.value && QUICK_IPS.lan) ipInput.value = QUICK_IPS.lan;
}
function initQuickIPButtons() {
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
btn.addEventListener('click', () => {
const ip = btn.dataset.ip;
if (ip) {
document.getElementById('service-ip-input').value = ip;
document.querySelectorAll('.quick-ip-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateServicePreview();
}
});
});
document.getElementById('service-ip-input')?.addEventListener('input', (e) => {
const currentIP = e.target.value;
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.ip === currentIP);
});
});
}
// ===== ADD SERVICE MODAL =====
async function openAddServiceModal() {
const modal = document.getElementById('add-service-modal');
modal.classList.add('show');
const modalContent = modal.querySelector('.weather-modal-content');
if (modalContent) modalContent.scrollTop = 0;
document.body.style.overflow = 'hidden';
// Set smart SSL default
const sslSelect = document.getElementById('ssl-type-select');
if (sslSelect) sslSelect.value = getSmartSslDefault();
await detectNetworkIPs();
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
await window.loadExistingCAs(caddyfilePath);
// Check Tailscale status
const tailscaleStatus = document.getElementById('manual-tailscale-status');
const tailscaleCheckbox = document.getElementById('manual-tailscale-only');
try {
const response = await fetch('/api/v1/tailscale/status');
const data = await response.json();
if (data.success && data.installed && data.connected) {
tailscaleStatus.innerHTML = `
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${data.self?.hostname} (${data.self?.ip})</span>
`;
tailscaleCheckbox.disabled = false;
} else if (data.installed) {
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">\u26A0 Not connected</span>`;
tailscaleCheckbox.disabled = true;
} else {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Not available</span>`;
tailscaleCheckbox.disabled = true;
}
} catch (e) {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check</span>`;
tailscaleCheckbox.disabled = true;
}
tailscaleCheckbox.checked = false;
updateServicePreview();
}
// ===== SERVICE TYPE SWITCHING (TAB STYLE) =====
function setupServiceTypeSwitching() {
const localRadio = document.getElementById('service-type-local');
const externalRadio = document.getElementById('service-type-external');
const localConfig = document.getElementById('local-service-config');
const externalConfig = document.getElementById('external-service-config');
const tabLocal = document.getElementById('tab-local');
const tabExternal = document.getElementById('tab-external');
function switchServiceType() {
if (localRadio.checked) {
localConfig.style.display = 'grid';
externalConfig.style.display = 'none';
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
} else {
localConfig.style.display = 'none';
externalConfig.style.display = 'block';
if (tabExternal) { tabExternal.style.background = 'var(--accent)'; tabExternal.style.color = 'var(--bg)'; }
if (tabLocal) { tabLocal.style.background = 'transparent'; tabLocal.style.color = 'var(--muted)'; }
}
}
localRadio?.addEventListener('change', switchServiceType);
externalRadio?.addEventListener('change', switchServiceType);
}
// ===== AUTO-DERIVE SUBDOMAIN FROM NAME =====
function setupAutoSubdomain() {
// Local service: name → subdomain + preview
const nameInput = document.getElementById('service-name-input');
const subdomainInput = document.getElementById('service-subdomain-input');
const subdomainPreview = document.getElementById('subdomain-preview');
let userEditedSubdomain = false;
nameInput?.addEventListener('input', () => {
const derived = deriveSubdomain(nameInput.value);
if (!userEditedSubdomain && subdomainInput) {
subdomainInput.value = derived;
}
if (subdomainPreview) {
subdomainPreview.textContent = derived ? `\u2192 ${buildDomain(derived)}` : '';
}
updateServicePreview();
});
subdomainInput?.addEventListener('input', () => {
userEditedSubdomain = subdomainInput.value !== deriveSubdomain(nameInput?.value || '');
const sub = subdomainInput.value.trim() || deriveSubdomain(nameInput?.value || '');
if (subdomainPreview) {
subdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
updateServicePreview();
});
// External service: name → subdomain + preview
const extNameInput = document.getElementById('external-service-name');
const extSubdomainInput = document.getElementById('external-service-subdomain');
const extSubdomainPreview = document.getElementById('external-subdomain-preview');
const extDomainPreview = document.getElementById('external-domain-preview');
let userEditedExtSubdomain = false;
extNameInput?.addEventListener('input', () => {
const derived = deriveSubdomain(extNameInput.value);
if (!userEditedExtSubdomain && extSubdomainInput) {
extSubdomainInput.value = derived;
}
const sub = extSubdomainInput?.value || derived;
if (extSubdomainPreview) {
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
if (extDomainPreview) {
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
}
});
extSubdomainInput?.addEventListener('input', () => {
userEditedExtSubdomain = extSubdomainInput.value !== deriveSubdomain(extNameInput?.value || '');
const sub = extSubdomainInput.value.trim() || deriveSubdomain(extNameInput?.value || '');
if (extSubdomainPreview) {
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
if (extDomainPreview) {
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
}
});
}
// ===== CREATE EXTERNAL SERVICE =====
async function createExternalService() {
const name = document.getElementById('external-service-name').value.trim();
const externalUrl = document.getElementById('external-service-url').value.trim();
const subdomain = (document.getElementById('external-service-subdomain').value.trim() || deriveSubdomain(name)).toLowerCase();
const logo = document.getElementById('external-service-logo').value.trim();
const icon = document.getElementById('external-service-icon').value.trim();
const createDns = document.getElementById('external-create-dns').checked;
const createCaddy = document.getElementById('external-create-caddy').checked;
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
const preserveHost = document.getElementById('external-preserve-host').checked;
const followRedirects = document.getElementById('external-follow-redirects').checked;
if (!name || !externalUrl) {
showNotification('Please fill in Name and External URL', 'warning');
return;
}
if (!subdomain) {
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
return;
}
if (!externalUrl.startsWith('http://') && !externalUrl.startsWith('https://')) {
showNotification('External URL must start with http:// or https://', 'warning');
return;
}
const domain = buildDomain(subdomain);
try {
const results = { dns: null, caddy: null, dashboard: false };
if (createDns) {
const adminToken = window.getToken('dns2', 'admin');
if (adminToken) {
try {
const dnsResponse = await secureFetch('/api/v1/dns/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: domain,
ip: proxyIp,
ttl: DC.DEFAULTS.TTL,
server: SITE.dnsIp
})
});
const dnsResult = await dnsResponse.json();
results.dns = dnsResult.success ? 'created' : (dnsResult.error || 'failed');
} catch (e) {
results.dns = e.message;
}
} else {
results.dns = 'no admin token (configure in \uD83D\uDD11 Tokens)';
}
}
if (createCaddy) {
try {
const caddyConfig = {
subdomain: subdomain,
externalUrl: externalUrl,
preserveHost: preserveHost,
followRedirects: followRedirects,
sslType: 'caddy-managed',
caddyfilePath: DC.DEFAULTS.CADDYFILE,
reloadCaddy: true
};
const caddyResponse = await secureFetch('/api/v1/site/external', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(caddyConfig)
});
const caddyResult = await caddyResponse.json();
results.caddy = caddyResult.success ? 'created' : (caddyResult.error || 'failed');
} catch (e) {
results.caddy = e.message;
}
}
const newService = {
id: subdomain,
name: name,
url: `https://${domain}`,
externalUrl: externalUrl,
logo: logo || icon || '\uD83C\uDF10',
isExternal: true,
isCustom: true
};
window.APPS.push(newService);
results.dashboard = true;
const defaultServices = ['plex', 'router', 'chat', 'sync', 'torrent', 'radarr', 'sonarr', 'prowlarr', 'portainer', 'requests', 'jellyfin', 'emby'];
const customServices = window.APPS.filter(app => !defaultServices.includes(app.id));
safeSet('custom-services', JSON.stringify(customServices));
try {
await secureFetch('/api/v1/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(window.APPS)
});
} catch (e) {
console.warn('Failed to save to services.json:', e);
}
window.buildGrid();
window.refreshAll();
closeAddServiceModal();
const parts = [`External service "${name}" added!`];
if (createDns) parts.push(`DNS: ${results.dns === 'created' ? '\u2713' : '\u26A0 ' + results.dns}`);
if (createCaddy) parts.push(`Caddy: ${results.caddy === 'created' ? '\u2713' : '\u26A0 ' + results.caddy}`);
parts.push(`Access at: https://${domain}`);
showNotification(parts.join(' | '), 'success', 6000);
} catch (error) {
console.error('Failed to create external service:', error);
showNotification(`Failed to create external service: ${error.message}`, 'error');
}
}
// ===== CLOSE ADD SERVICE MODAL =====
function closeAddServiceModal() {
closeModal('add-service-modal');
document.body.style.overflow = '';
document.getElementById('service-name-input').value = '';
document.getElementById('service-subdomain-input').value = '';
document.getElementById('service-port-input').value = '';
document.getElementById('service-ip-input').value = QUICK_IPS.lan || '';
document.getElementById('service-logo-input').value = '';
document.getElementById('dns-ttl-input').value = DC.DEFAULTS.TTL;
document.getElementById('ssl-type-select').value = getSmartSslDefault();
document.getElementById('ca-name-input').value = '';
document.getElementById('enable-auth').checked = false;
document.getElementById('enable-cors').checked = false;
document.getElementById('custom-headers-input').value = '';
document.getElementById('upstream-path-input').value = '/';
document.getElementById('health-check-input').value = '';
document.getElementById('timeout-input').value = '30';
// Clear subdomain previews
const subPrev = document.getElementById('subdomain-preview');
if (subPrev) subPrev.textContent = '';
const extSubPrev = document.getElementById('external-subdomain-preview');
if (extSubPrev) extSubPrev.textContent = '';
// Clear external fields
const extName = document.getElementById('external-service-name');
if (extName) extName.value = '';
const extSub = document.getElementById('external-service-subdomain');
if (extSub) extSub.value = '';
const extUrl = document.getElementById('external-service-url');
if (extUrl) extUrl.value = '';
const extLogo = document.getElementById('external-service-logo');
if (extLogo) extLogo.value = '';
const extIcon = document.getElementById('external-service-icon');
if (extIcon) extIcon.value = '';
// Collapse options
const localOpts = document.getElementById('local-advanced-options');
if (localOpts) localOpts.removeAttribute('open');
const extOpts = document.getElementById('external-advanced-options');
if (extOpts) extOpts.removeAttribute('open');
// Reset to local tab
const localRadio = document.getElementById('service-type-local');
if (localRadio) localRadio.checked = true;
const localConfig = document.getElementById('local-service-config');
const externalConfig = document.getElementById('external-service-config');
if (localConfig) localConfig.style.display = 'grid';
if (externalConfig) externalConfig.style.display = 'none';
const tabLocal = document.getElementById('tab-local');
const tabExternal = document.getElementById('tab-external');
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
}
// ===== CREATE NEW SERVICE =====
async function createNewService() {
const name = document.getElementById('service-name-input').value.trim();
const subdomain = (document.getElementById('service-subdomain-input').value.trim() || deriveSubdomain(name)).toLowerCase();
const port = document.getElementById('service-port-input').value.trim();
const ip = document.getElementById('service-ip-input').value.trim();
const logo = document.getElementById('service-logo-input').value.trim();
const createDns = document.getElementById('create-dns-record').checked;
const ttl = parseInt(document.getElementById('dns-ttl-input').value) || DC.DEFAULTS.TTL;
const tailscaleOnly = document.getElementById('manual-tailscale-only')?.checked || false;
const sslType = document.getElementById('ssl-type-select')?.value || 'caddy-managed';
const caName = document.getElementById('ca-name-input')?.value || '';
const existingCa = document.getElementById('existing-ca-select')?.value || '';
const enableAuth = document.getElementById('enable-auth')?.checked || false;
const enableCors = document.getElementById('enable-cors')?.checked || false;
const customHeaders = document.getElementById('custom-headers-input')?.value || '';
const upstreamPath = document.getElementById('upstream-path-input')?.value || '/';
const healthCheck = document.getElementById('health-check-input')?.value || '';
const timeout = document.getElementById('timeout-input')?.value || 30;
const dnsToken = window.getToken('dns2', 'admin');
if (!name || !port || !ip) {
showNotification('Please fill in Name, Port, and IP Address', 'warning');
return;
}
if (!subdomain) {
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
return;
}
if (createDns && !dnsToken) {
showNotification('DNS Admin token required. Configure it in the Tokens menu first.', 'warning');
return;
}
const results = { dns: null, caddy: null, dashboard: false };
try {
if (createDns) {
try {
await window.createDnsRecord(subdomain, ip, ttl);
results.dns = 'created';
} catch (error) {
console.error('DNS creation failed:', error);
results.dns = error.message;
throw new Error(`DNS creation failed: ${error.message}`);
}
} else {
results.dns = 'skipped';
}
const caddyConfig = window.generateCaddyConfig({
subdomain, port, ip, sslType, caName, existingCa,
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout, tailscaleOnly
});
try {
const caddyResponse = await secureFetch('/api/v1/site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: buildDomain(subdomain),
upstream: `${ip}:${port}`,
config: caddyConfig
})
});
const caddyResult = await caddyResponse.json();
if (caddyResult.success) {
results.caddy = 'added & reloaded';
} else {
console.error('Caddy configuration failed:', caddyResult.error);
results.caddy = caddyResult.error || 'failed';
throw new Error(`Caddy configuration failed: ${caddyResult.error}`);
}
} catch (error) {
console.error('Caddy API error:', error);
results.caddy = error.message;
throw new Error(`Caddy API error: ${error.message}`);
}
const serviceConfig = {
name, subdomain, port, ip,
logo: logo || `/assets/${subdomain}.png`,
tailscaleOnly: tailscaleOnly || false
};
await window.addServiceToConfig(serviceConfig);
results.dashboard = true;
const statusParts = [
`DNS: ${results.dns === 'created' ? '\u2713' : results.dns === 'skipped' ? '\u25CB' : '\u2717'}`,
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
`Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}`
];
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 ${buildServiceUrl(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
closeAddServiceModal();
window.buildGrid();
window.refreshAll();
} catch (error) {
console.error('Error creating service:', error);
showNotification(`Error creating "${name}": ${error.message}`, 'error', 6000);
}
}
// ===== EVENT LISTENERS =====
document.getElementById('add-service')?.addEventListener('click', openAddServiceModal);
document.getElementById('add-service-cancel')?.addEventListener('click', closeAddServiceModal);
document.getElementById('add-service-create')?.addEventListener('click', () => {
const serviceType = document.querySelector('input[name="service-type"]:checked')?.value;
if (serviceType === 'external') {
createExternalService();
} else {
createNewService();
}
});
setupServiceTypeSwitching();
setupAutoSubdomain();
initQuickIPButtons();
// SSL type change handler
document.getElementById('ssl-type-select')?.addEventListener('change', (e) => {
const existingCaConfig = document.getElementById('existing-ca-config');
const customCaConfig = document.getElementById('custom-ca-config');
existingCaConfig.style.display = 'none';
customCaConfig.style.display = 'none';
if (e.target.value === 'existing-ca') {
existingCaConfig.style.display = 'block';
} else if (e.target.value === 'custom-ca') {
customCaConfig.style.display = 'block';
}
updateServicePreview();
});
// Refresh CAs button
document.getElementById('refresh-cas')?.addEventListener('click', async () => {
const button = document.getElementById('refresh-cas');
const originalText = button.textContent;
button.textContent = '\u231B Loading...';
button.disabled = true;
try {
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
await window.loadExistingCAs(caddyfilePath);
button.textContent = '\u2705 Refreshed';
} catch (error) {
button.textContent = '\u274C Failed';
console.error('Failed to refresh CAs:', error);
}
setTimeout(() => {
button.textContent = originalText;
button.disabled = false;
}, 2000);
});
// DNS record checkbox handler
document.getElementById('create-dns-record')?.addEventListener('change', (e) => {
const dnsConfig = document.getElementById('dns-config');
dnsConfig.style.display = e.target.checked ? 'block' : 'none';
});
// Real-time preview updates
['service-subdomain-input', 'service-ip-input', 'service-port-input', 'ca-name-input',
'existing-ca-select', 'enable-auth', 'enable-cors', 'custom-headers-input', 'upstream-path-input',
'health-check-input', 'timeout-input'].forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('input', updateServicePreview);
element.addEventListener('change', updateServicePreview);
}
});
// ===== CUSTOM SERVICES FROM LOCALSTORAGE =====
function loadCustomServices() {
const customServices = safeGet('custom-services');
if (customServices) {
try {
const services = JSON.parse(customServices);
services.forEach(service => {
if (!window.APPS.find(app => app.id === service.id)) {
window.APPS.push(service);
}
});
} catch (e) {
console.warn('Failed to load custom services:', e);
}
}
}
loadCustomServices();
// ===== WINDOW EXPORTS =====
window.openAddServiceModal = openAddServiceModal;
window.closeAddServiceModal = closeAddServiceModal;
})();