// 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 += '
| # | Rule | Action |
';
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 += '| ' + (i + 1) + ' | ' +
'' + escHtml(ruleText) + ' | ' +
' |
';
});
html += '
';
} else {
html += '
No rich rules configured for this zone.
';
}
html += '
';
});
return html || '';
};
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 ? ' (' + 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,'>');
};