// Toast notifications const showToast = (message, type, duration = 4000) => { const container = document.getElementById('toast-container'); if (!container) return; const toast = document.createElement('div'); toast.className = 'toast toast-' + type; toast.textContent = message; container.appendChild(toast); requestAnimationFrame(() => toast.classList.add('show')); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, duration); }; const showSuccessToast = (msg) => showToast(msg, 'success'); const showErrorToast = (msg) => showToast(msg, 'error'); const showWarningToast = (msg) => showToast(msg, 'warning'); // Modal helpers const openModal = (id) => { const el = document.getElementById(id); if (el) el.classList.add('active'); }; const closeModal = (id) => { const el = document.getElementById(id); if (el) el.classList.remove('active'); }; // Tab switching let switchTab = (tabName) => { document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')); document.getElementById('tab-' + tabName).classList.add('active'); const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]'); if (clickedTab) clickedTab.classList.add('active'); }; // Refresh a container from a JSON GET endpoint using a renderer callback const refreshTable = (url, container, renderer) => { fetch(url) .then(r => r.json()) .then(data => { const json = data.ok ? data.data : data; container.innerHTML = renderer(json); htmx.process(container); }) .catch(() => {}); }; // HTMX event handlers document.body.addEventListener('htmx:afterSwap', (evt) => { const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast'); if (toastHeader) { const parts = toastHeader.split(':'); const msg = parts.slice(1).join(':').trim(); showToast(msg, parts[0]?.trim() || 'info'); } }); document.body.addEventListener('htmx:responseError', (evt) => { const status = evt.detail.xhr?.status || 0; const json = evt.detail.xhr?.response; let msg = 'Request failed (' + status + ')'; try { const parsed = JSON.parse(json); if (parsed.error) msg = parsed.error; } catch (e) {} showToast(msg, 'error'); }); document.body.addEventListener('htmx:beforeRequest', (evt) => { const btn = evt.target.closest('.btn'); if (btn) { btn.dataset.originalText = btn.textContent; btn.disabled = true; btn.textContent = 'Loading...'; } }); document.body.addEventListener('htmx:afterRequest', (evt) => { const btn = evt.target.closest('.btn'); if (btn && btn.dataset.originalText !== undefined) { btn.disabled = false; btn.textContent = btn.dataset.originalText; delete btn.dataset.originalText; } }); // Keyboard: Escape closes all modals document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active')); } }); // -------- Renderer helpers for htmx-driven DOM updates -------- const renderZones = (data) => { const active = Array.isArray(data) ? data : (data.active || []); if (!active.length) return '
No zones configured. Create a zone to get started.
'; return active.map(zone => '
' + '
' + '

' + escHtml(zone.name) + '

' + '
' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '
' + '
Interfaces
' + (zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '' + escHtml(i) + '').join('') : 'None') + '
Services
' + (zone.services && zone.services.length ? zone.services.map(s => '' + escHtml(s) + '').join('') : 'None') + '
' + '
' + '
' ).join(''); }; const renderRules = (data) => { let html = ''; let zoneRules = {}; const cfgZones = data && data.zones ? data.zones : null; if (cfgZones) { Object.keys(cfgZones).forEach(zname => { const rr = cfgZones[zname].rich_rules || []; if (rr.length) zoneRules[zname] = rr; }); } else { zoneRules = data || {}; } Object.keys(zoneRules).forEach(zone => { let entries = zoneRules[zone]; if (!Array.isArray(entries)) entries = []; html += '

Zone: ' + escHtml(zone || '(default)') + '

'; if (entries.length) { html += ''; entries.forEach((entry, i) => { let ruleId, ruleText; if (typeof entry === 'object' && entry.rule) { ruleId = entry.id; ruleText = entry.rule; } else { ruleId = null; ruleText = String(entry); } html += '' + '' + ''; }); html += '
#RuleAction
' + (i + 1) + '' + escHtml(ruleText) + '
' + '
'; } else { html += '
No rich rules configured for this zone.
'; } html += '
'; }); return html || '
No rules loaded.
'; }; const renderForwards = (forwards) => { if (!forwards.length) return 'No port forwarding rules configured'; return forwards.map(fwd => { const proto = fwd['proxy-protocol'] || fwd.proto; return '' + escHtml(fwd.zone) + '' + '' + escHtml(proto) + '' + '' + fwd.port + '' + escHtml(fwd['to-addr'] || fwd.toaddr) + '' + '' + (fwd['to-port'] || fwd.toport || '-') + '' + '
' + '
'; }).join(''); }; const renderForwardsFromConfig = (data) => { const zones = data.zones || {}; const forwards = []; Object.keys(zones).forEach(name => { zones[name].forward_ports = zones[name].forward_ports || []; zones[name].forward_ports.forEach(fwd => { forwards.push({ zone: name, 'proxy-protocol': fwd['proxy-protocol'] || fwd.proto, port: fwd.port, 'to-addr': fwd['to-addr'] || fwd.toaddr, 'to-port': fwd['to-port'] || fwd.toport }); }); }); return renderForwards(forwards); }; const renderRanges = (ranges) => { if (!ranges.length) return 'No DHCP ranges configured'; return ranges.map(rng => '' + escHtml(rng.interface || '(global)') + '' + '' + escHtml(rng.start) + '' + escHtml(rng.end) + '' + '' + escHtml(rng.lease_time || '1h') + '' + '
' + '
' ).join(''); }; const renderStaticLeases = (leases) => { if (!leases.length) return 'No static leases configured'; return leases.map(lease => '' + escHtml(lease.mac) + '' + escHtml(lease.ip) + '' + '' + escHtml(lease.hostname || '-') + '' + '
' + '
' ).join(''); }; const renderDnsRecords = (records) => { if (!records.length) return 'No custom DNS records'; return records.map(rec => '' + escHtml(rec.name || 'unnamed') + '' + '' + escHtml(rec.address || '-') + '' + '
' + '
' ).join(''); }; const renderDomains = (domains) => { if (!domains.length) return 'No proxy domains configured. Add a domain to start terminating SSL.'; return domains.map(d => { let certHtml = '' + (d.cert_status || 'No cert') + ''; if (d.cert_status === 'expired') certHtml = 'Expired'; else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = 'Valid'; else if (typeof d.days_remaining === 'number') { if (d.days_remaining <= 0) certHtml = 'Expired'; else if (d.days_remaining <= 30) certHtml = '' + d.days_remaining + 'd'; else certHtml = 'Valid'; } return '' + escHtml(d.domain) + '' + '' + escHtml(d.backend_host || '-') + '' + '' + (d.backend_port || '-') + '' + '' + escHtml(d.protocol || 'http') + '' + '' + certHtml + '' + '
' + '' + '
' + '
'; }).join(''); }; const renderPeers = (peers) => { if (!peers.length) return 'No peers configured. Add a peer above.'; return peers.map(peer => '' + '' + escHtml(peer.name || 'unnamed') + '' + '' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...' + '' + escHtml(peer.allowed_ips || '-') + '' + '' + escHtml(peer.endpoint || '-') + '' + '' + escHtml(peer.latest_handshake || 'Never') + '' + '
Recv: ' + escHtml(peer.transfer_recv || '0') + '
Sent: ' + escHtml(peer.transfer_sent || '0') + '
' + '
' + '' + '
' + '
' ).join(''); }; const renderCerts = (certs) => { if (!certs.length) return 'No certificates found. Issue a certificate to get started.'; return certs.map(cert => { const days = cert.days_remaining; let badgeHtml; if (cert.expired || (days !== undefined && days <= 0)) { badgeHtml = 'Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + ''; } else if (days !== undefined && days <= 30) { badgeHtml = '' + days + ' days'; } else { badgeHtml = '' + (days !== undefined ? days + ' days' : 'N/A') + ''; } return '' + escHtml(cert.domain || 'unknown') + '' + '' + escHtml(cert.issuer || '-') + '' + '' + escHtml(cert.expiry || 'N/A') + '' + '' + badgeHtml + '' + '
' + '
'; }).join(''); }; const renderInterfaces = (interfaces) => { if (!interfaces.length) return 'No interfaces found'; return interfaces.map(iface => { const zoneOptions = (iface.zones || []).map(z => '' ).join(''); return '' + escHtml(iface.name) + '' + '' + escHtml(iface.mac || 'N/A') + '' + '' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '' + '' + (iface.state === 'up' ? 'Up' : 'Down') + '' + ''; }).join(''); }; const assignZone = (ifaceName, selectEl) => { fetch('/api/firewall/zones/' + encodeURIComponent(selectEl.value) + '/interfaces', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ interfaces: [ifaceName] }) }) .then(r => { if (r.ok) { showSuccessToast(ifaceName + ' assigned to ' + selectEl.value); refreshTable('/api/firewall/interfaces', document.getElementById('interface-list'), renderInterfaces); } else return r.json().then(j => { throw new Error(j.error || r.statusText); }); }) .catch(e => { showErrorToast(e.message); }); }; const escHtml = (s) => { const div = document.createElement('div'); div.appendChild(document.createTextNode(s)); return div.innerHTML; }; const escAttr = (s) => { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(//g,'>'); }; // ─── Certificate Issue Wizard ──────────────────────────────────────── let _issuePollHandle = null; let _issueRequestId = null; function closeIssueWizard() { if (_issuePollHandle) { clearInterval(_issuePollHandle); _issuePollHandle = null; } _issueRequestId = null; resetIssueWizard(); closeModal('issue-cert-modal'); } function resetIssueWizard() { document.getElementById('cert-wizard-input').style.display = ''; document.getElementById('cert-wizard-progress').style.display = 'none'; document.getElementById('cert-check-results').style.display = 'none'; document.getElementById('cert-check-btn').style.display = ''; document.getElementById('cert-issue-btn').style.display = 'none'; document.getElementById('cert-close-progress').style.display = 'none'; } function validateCertIssue() { const domain = document.getElementById('cert-domain').value.trim(); if (!domain) { showErrorToast('Domain is required'); return; } const email = document.getElementById('cert-email').value.trim() || undefined; const checkBtn = document.getElementById('cert-check-btn'); checkBtn.disabled = true; checkBtn.textContent = 'Checking...'; fetch('/api/certs/validate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ domain, email }) }) .then(r => r.json()) .then(data => { checkBtn.disabled = false; checkBtn.textContent = 'Check'; const result = data.ok ? data.data : data; renderChecks(result.checks); if (result.ready) { document.getElementById('cert-check-btn').style.display = 'none'; document.getElementById('cert-issue-btn').style.display = ''; } else { document.getElementById('cert-issue-btn').style.display = 'none'; } }) .catch(e => { checkBtn.disabled = false; checkBtn.textContent = 'Check'; showErrorToast('Validation failed: ' + e.message); }); } function renderChecks(checks) { const container = document.getElementById('cert-checks-list'); const resultsDiv = document.getElementById('cert-check-results'); resultsDiv.style.display = ''; container.innerHTML = checks.map(c => { let icon, badge; if (c.passed) { icon = '✓'; badge = c.blocking ? 'badge-success' : 'badge-info'; } else { icon = '✗'; badge = 'badge-danger'; } return '
' + '' + icon + '' + '' + escHtml(c.name).replace(/_/g, ' ') + '' + '' + escHtml(c.message || '') + '' + '
'; }).join(''); } function startCertIssue() { const domain = document.getElementById('cert-domain').value.trim(); const email = document.getElementById('cert-email').value.trim() || undefined; document.getElementById('cert-wizard-input').style.display = 'none'; document.getElementById('cert-wizard-progress').style.display = ''; document.getElementById('cert-steps-list').innerHTML = '
Starting certificate issuance…
'; fetch('/api/certs/issue/start', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ domain, email }) }) .then(r => r.json()) .then(data => { const result = data.ok ? data.data : data; _issueRequestId = result.request_id; if (!result.request_id) throw new Error('No request_id returned'); // If issuance already exists for this domain, follow the existing request startIssuePoll(result.request_id); }) .catch(e => { showErrorToast('Failed to start issuance: ' + e.message); // Fall back to input phase document.getElementById('cert-wizard-input').style.display = ''; document.getElementById('cert-wizard-progress').style.display = 'none'; }); } function startIssuePoll(requestId) { _issueRequestId = requestId; _issuePollHandle = setInterval(() => pollIssueStatus(requestId), 2000); // Also poll immediately pollIssueStatus(requestId); } function pollIssueStatus(requestId) { fetch('/api/certs/issue/' + encodeURIComponent(requestId)) .then(r => r.json()) .then(data => { const result = data.ok ? data.data : data; renderIssueSteps(result.steps, result.status); if (result.status === 'completed') { clearInterval(_issuePollHandle); _issuePollHandle = null; document.getElementById('cert-close-progress').style.display = ''; showSuccessToast('Certificate issued for ' + result.domain); } else if (result.status === 'failed') { clearInterval(_issuePollHandle); _issuePollHandle = null; // Show failed — user can see which step failed document.getElementById('cert-close-progress').style.display = ''; showErrorToast('Certificate issuance failed for ' + result.domain); } }) .catch(e => { // Don't poll on error — but keep trying since request might still be running }); } function renderIssueSteps(steps, status) { const container = document.getElementById('cert-steps-list'); if (!steps || !steps.length) { container.innerHTML = '
Pending…
'; return; } container.innerHTML = steps.map(s => { let icon; if (s.status === 'done') icon = ''; else if (s.status === 'running') icon = ''; else if (s.status === 'error') icon = ''; else icon = ''; return '
' + icon + '' + escHtml(s.label) + '' + (s.status === 'running' ? '(in progress…)' : s.status === 'error' ? '' + escHtml(s.message || 'failed') + '' : 'done') + '
'; }).join(''); if (status === 'completed') { container.innerHTML += '
✓ Certificate issued
'; } } // ─── Network Interface Config helpers ───────────────────────────── const saveInterfaceConfig = (ifaceName) => { const addrs = (document.getElementById('addrs-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean); const gateway = (document.getElementById('gw-' + ifaceName)?.value || '').trim(); const dns = (document.getElementById('dns-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean); const routesContainer = document.getElementById('routes-' + ifaceName); let routes = []; if (routesContainer) { routes = Array.from(routesContainer.querySelectorAll('.route-row')).map(row => { const dest = (row.querySelector('.route-dest')?.value || '').trim(); const gw = (row.querySelector('.route-gw')?.value || '').trim(); if (dest || gw) return { destination: dest, gateway: gw }; return null; }).filter(Boolean); } fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ addresses: addrs, gateway: gateway || undefined, dns: dns, routes: routes }) }) .then(r => r.json()) .then(data => { if (data.ok && data.data && data.data.applied === false) { showWarningToast('Config saved for ' + ifaceName + ' (system deploy skipped — not running as privileged)'); } else if (data.ok) { showSuccessToast('Config saved for ' + ifaceName); } else { showErrorToast(data.error || 'Failed to save config'); } }) .catch(e => { showErrorToast('Failed to save config: ' + e.message); }); }; const reloadNetworkd = (ifaceName) => { fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName) + '/reload', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.ok) { showSuccessToast('Network reload triggered for ' + ifaceName); } else { showErrorToast(data.error || 'Reload failed'); } }) .catch(e => { showErrorToast('Reload failed: ' + e.message); }); }; const toggleRoutes = (ifaceName) => { const panel = document.getElementById('routes-panel-' + ifaceName); if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; }; const addRoute = (ifaceName) => { const container = document.getElementById('routes-' + ifaceName); if (!container) return; const row = document.createElement('div'); row.className = 'route-row'; row.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:4px;'; row.innerHTML = '' + '' + ''; container.appendChild(row); }; const renderNetworkRoutes = (routes, containerId) => { const container = document.getElementById(containerId); if (!container) return; const safe = (s) => escHtml(String(s || '')); container.innerHTML = (routes || []) .map((r, i) => '
' + '' + '' + '
' ).join('') || '
No static routes
'; };