// ========== 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 = ` \u2713 Connected ${data.self?.hostname} (${data.self?.ip}) `; tailscaleCheckbox.disabled = false; } else if (data.installed) { tailscaleStatus.innerHTML = `\u26A0 Not connected`; tailscaleCheckbox.disabled = true; } else { tailscaleStatus.innerHTML = `Not available`; tailscaleCheckbox.disabled = true; } } catch (e) { tailscaleStatus.innerHTML = `Could not check`; 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; })();