// 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 += '
| # | 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 && 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
';
};