// 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'); // 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 const 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,'>'); };