refactor: replace Jinja templates with static frontend pages
This commit is contained in:
+92
-565
@@ -1,578 +1,105 @@
|
||||
// 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);
|
||||
import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=4';
|
||||
|
||||
import DashboardPage from '/static/pages/dashboard.js?v=4';
|
||||
import InterfacesPage from '/static/pages/interfaces.js?v=4';
|
||||
import ZonesPage from '/static/pages/zones.js?v=4';
|
||||
import RulesPage from '/static/pages/rules.js?v=4';
|
||||
import NatPage from '/static/pages/nat.js?v=4';
|
||||
import DhcpPage from '/static/pages/dhcp.js?v=4';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=4';
|
||||
import CertsPage from '/static/pages/certs.js?v=4';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=4';
|
||||
import LogsPage from '/static/pages/logs.js?v=4';
|
||||
import NotFoundPage from '/static/pages/notfound.js?v=4';
|
||||
|
||||
/* ── Navigation items ──────────────────────────────────────── */
|
||||
const Nav = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/interfaces', label: 'Interfaces' },
|
||||
{ path: '/zones', label: 'Zones' },
|
||||
{ path: '/rules', label: 'Rules' },
|
||||
{ path: '/nat', label: 'NAT' },
|
||||
{ path: '/dhcp', label: 'DHCP' },
|
||||
{ path: '/proxy', label: 'Proxy' },
|
||||
{ path: '/certs', label: 'Certs' },
|
||||
{ path: '/wireguard', label: 'WireGuard' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
];
|
||||
|
||||
/* ── Page map ──────────────────────────────────────────────── */
|
||||
const Pages = {
|
||||
dashboard: DashboardPage,
|
||||
interfaces: InterfacesPage,
|
||||
zones: ZonesPage,
|
||||
rules: RulesPage,
|
||||
nat: NatPage,
|
||||
dhcp: DhcpPage,
|
||||
proxy: ProxyPage,
|
||||
certs: CertsPage,
|
||||
wireguard: WireguardPage,
|
||||
logs: LogsPage,
|
||||
};
|
||||
|
||||
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');
|
||||
/* ── Router ────────────────────────────────────────────────── */
|
||||
const router = {
|
||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||
component() {
|
||||
const name = this.state.path.replace(/^\//, '');
|
||||
const page = Pages[name] || NotFoundPage;
|
||||
return hComp(page, this.state.path);
|
||||
},
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
window.location.hash || (window.location.hash = router.state.path);
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
||||
});
|
||||
|
||||
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 '<div class="card"><div class="text-muted text-sm">No zones configured. Create a zone to get started.</div></div>';
|
||||
return active.map(zone =>
|
||||
'<div class="card" style="position:relative;">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
|
||||
'<div><h3 style="font-size:16px;color:var(--accent);">' + escHtml(zone.name) + '</h3>' +
|
||||
'<div class="text-muted text-sm" style="margin-bottom:10px;">' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '</div></div></div>' +
|
||||
'<div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Interfaces</div>' +
|
||||
(zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '<span class="badge badge-info">' + escHtml(i) + '</span>').join('') : '<span class="text-muted">None</span>') +
|
||||
'</div><div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Services</div>' +
|
||||
(zone.services && zone.services.length ? zone.services.map(s => '<span class="badge badge-success">' + escHtml(s) + '</span>').join('') : '<span class="text-muted">None</span>') +
|
||||
'</div><div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">' +
|
||||
'<form hx-delete="/api/firewall/zones/' + escAttr(zone.name) + '" hx-swap="none" hx-confirm="Delete zone ' + escHtml(zone.name) + '? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/zones\', document.getElementById(\'zone-grid\'), renderZones); showSuccessToast(\'Zone deleted\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></div>'
|
||||
).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 += '<div class="card"><h3>Zone: <span style="color:var(--accent);">' + escHtml(zone || '(default)') + '</span></h3>';
|
||||
if (entries.length) {
|
||||
html += '<table><thead><tr><th>#</th><th>Rule</th><th style="width:80px;">Action</th></tr></thead><tbody>';
|
||||
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 += '<tr><td class="text-muted">' + (i + 1) + '</td>' +
|
||||
'<td style="font-family:monospace;font-size:12px;word-break:break-all;" hx-disable>' + escHtml(ruleText) + '</td>' +
|
||||
'<td><form hx-delete="/api/firewall/rich-rules/' + escAttr(zone) + (ruleId ? '/' + encodeURIComponent(ruleId) : '') + '"' +
|
||||
(ruleId ? '' : ' hx-encoding="json" hx-vals=\'{"rule": ' + JSON.stringify(ruleText) + ' }\'') +
|
||||
' hx-swap="none" hx-confirm="Remove rule ' + escHtml(ruleText.substring(0, 40)) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'rules-container\'), renderRules); showSuccessToast(\'Rule removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
html += '<div class="text-muted text-sm">No rich rules configured for this zone.</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
return html || '<div class="card"><div class="text-muted text-sm">No rules loaded.</div></div>';
|
||||
};
|
||||
|
||||
const renderForwards = (forwards) => {
|
||||
if (!forwards.length) return '<tr><td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td></tr>';
|
||||
return forwards.map(fwd => {
|
||||
const proto = fwd['proxy-protocol'] || fwd.proto;
|
||||
return '<tr><td><strong>' + escHtml(fwd.zone) + '</strong></td>' +
|
||||
'<td><span class="badge badge-info">' + escHtml(proto) + '</span></td>' +
|
||||
'<td>' + fwd.port + '</td><td>' + escHtml(fwd['to-addr'] || fwd.toaddr) + '</td>' +
|
||||
'<td>' + (fwd['to-port'] || fwd.toport || '-') + '</td>' +
|
||||
'<td><form hx-delete="/api/firewall/forward-port/' + encodeURIComponent(fwd.zone) + '/' + fwd.port + '/' + encodeURIComponent(proto) + '" hx-swap="none" hx-confirm="Remove forward rule ' + fwd.port + '/' + proto + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'forward-rows\'), renderForwardsFromConfig); showSuccessToast(\'Rule removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
|
||||
}).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 '<tr><td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td></tr>';
|
||||
return ranges.map(rng =>
|
||||
'<tr><td>' + escHtml(rng.interface || '(global)') + '</td>' +
|
||||
'<td>' + escHtml(rng.start) + '</td><td>' + escHtml(rng.end) + '</td>' +
|
||||
'<td>' + escHtml(rng.lease_time || '1h') + '</td>' +
|
||||
'<td><form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals=\'{"interface": "' + escAttr(rng.interface || '') + '", "start": "' + escAttr(rng.start) + '", "end": "' + escAttr(rng.end) + '"}\' hx-swap="none" hx-confirm="Remove DHCP range ' + escHtml(rng.start) + ' - ' + escHtml(rng.end) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'range-rows\'), renderRanges); showSuccessToast(\'Range removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderStaticLeases = (leases) => {
|
||||
if (!leases.length) return '<tr><td colspan="4" class="text-muted text-sm">No static leases configured</td></tr>';
|
||||
return leases.map(lease =>
|
||||
'<tr><td>' + escHtml(lease.mac) + '</td><td>' + escHtml(lease.ip) + '</td>' +
|
||||
'<td>' + escHtml(lease.hostname || '-') + '</td>' +
|
||||
'<td><form hx-delete="/api/dhcp/static-lease/' + encodeURIComponent(lease.mac) + '" hx-swap="none" hx-confirm="Remove lease ' + escHtml(lease.mac) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'lease-rows\'), renderStaticLeases); showSuccessToast(\'Lease removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderDnsRecords = (records) => {
|
||||
if (!records.length) return '<tr><td colspan="3" class="text-muted text-sm">No custom DNS records</td></tr>';
|
||||
return records.map(rec =>
|
||||
'<tr><td><strong>' + escHtml(rec.name || 'unnamed') + '</strong></td>' +
|
||||
'<td class="text-sm">' + escHtml(rec.address || '-') + '</td>' +
|
||||
'<td><form hx-delete="/api/dhcp/dns-record/' + encodeURIComponent(rec.name) + '" hx-swap="none" hx-confirm="Remove DNS record ' + escHtml(rec.name || 'unnamed') + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'dns-rows\'), renderDnsRecords); showSuccessToast(\'Record removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderDomains = (domains) => {
|
||||
if (!domains.length) return '<tr><td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td></tr>';
|
||||
return domains.map(d => {
|
||||
let certHtml = '<span class="badge badge-danger">' + (d.cert_status || 'No cert') + '</span>';
|
||||
if (d.cert_status === 'expired') certHtml = '<span class="badge badge-danger">Expired</span>';
|
||||
else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = '<span class="badge badge-success">Valid</span>';
|
||||
else if (typeof d.days_remaining === 'number') {
|
||||
if (d.days_remaining <= 0) certHtml = '<span class="badge badge-danger">Expired</span>';
|
||||
else if (d.days_remaining <= 30) certHtml = '<span class="badge badge-warning">' + d.days_remaining + 'd</span>';
|
||||
else certHtml = '<span class="badge badge-success">Valid</span>';
|
||||
}
|
||||
return '<tr><td><strong>' + escHtml(d.domain) + '</strong></td>' +
|
||||
'<td>' + escHtml(d.backend_host || '-') + '</td>' +
|
||||
'<td>' + (d.backend_port || '-') + '</td>' +
|
||||
'<td><span class="badge badge-info">' + escHtml(d.protocol || 'http') + '</span></td>' +
|
||||
'<td>' + certHtml + '</td>' +
|
||||
'<td><div class="flex gap-2">' +
|
||||
'<button class="btn btn-sm btn-outline" onclick="openEditDomainModal(\'' + escAttr(d.domain) + '\', ' + JSON.stringify(d) + ')">Edit</button>' +
|
||||
'<form hx-delete="/api/proxy/domains/' + escAttr(d.domain) + '" hx-swap="none" hx-confirm="Remove proxy for ' + escHtml(d.domain) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/proxy/domains\', document.getElementById(\'domain-rows\'), renderDomains); showSuccessToast(\'Domain removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></td></tr>';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const renderPeers = (peers) => {
|
||||
if (!peers.length) return '<tr><td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td></tr>';
|
||||
return peers.map(peer =>
|
||||
'<tr><td><span class="status-dot ' + (peer.latest_handshake ? 'status-up' : 'status-down') + '"></span>' +
|
||||
'<strong>' + escHtml(peer.name || 'unnamed') + '</strong></td>' +
|
||||
'<td style="font-family:monospace;font-size:11px;">' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...</td>' +
|
||||
'<td class="text-sm">' + escHtml(peer.allowed_ips || '-') + '</td>' +
|
||||
'<td class="text-sm">' + escHtml(peer.endpoint || '-') + '</td>' +
|
||||
'<td class="text-sm">' + escHtml(peer.latest_handshake || 'Never') + '</td>' +
|
||||
'<td class="text-sm"><div>Recv: ' + escHtml(peer.transfer_recv || '0') + '</div><div>Sent: ' + escHtml(peer.transfer_sent || '0') + '</div></td>' +
|
||||
'<td><div class="flex gap-2">' +
|
||||
'<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig(\'' + escAttr(peer.name) + '\')">Config</button>' +
|
||||
'<form hx-delete="/api/wireguard/peers/' + encodeURIComponent(peer.name) + '" hx-swap="none" hx-confirm="Remove peer ' + escHtml(peer.name) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/wireguard/peers\', document.getElementById(\'peer-rows\'), renderPeers); showSuccessToast(\'Peer removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></div></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderCerts = (certs) => {
|
||||
if (!certs.length) return '<tr><td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td></tr>';
|
||||
return certs.map(cert => {
|
||||
const days = cert.days_remaining;
|
||||
let badgeHtml;
|
||||
if (cert.expired || (days !== undefined && days <= 0)) {
|
||||
badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + '</span>';
|
||||
} else if (days !== undefined && days <= 30) {
|
||||
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
|
||||
} else {
|
||||
badgeHtml = '<span class="badge badge-success">' + (days !== undefined ? days + ' days' : 'N/A') + '</span>';
|
||||
}
|
||||
return '<tr><td><strong>' + escHtml(cert.domain || 'unknown') + '</strong></td>' +
|
||||
'<td class="text-sm">' + escHtml(cert.issuer || '-') + '</td>' +
|
||||
'<td>' + escHtml(cert.expiry || 'N/A') + '</td>' +
|
||||
'<td>' + badgeHtml + '</td>' +
|
||||
'<td><form hx-post="/api/certs/' + escAttr(cert.domain) + '/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/certs/list\', document.getElementById(\'cert-rows\'), renderCerts); showSuccessToast(\'Renewal started for ' + escHtml(cert.domain) + '\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-outline">Renew</button></form></td></tr>';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const renderInterfaces = (interfaces) => {
|
||||
if (!interfaces.length) return '<tr><td colspan="5" class="text-muted text-sm">No interfaces found</td></tr>';
|
||||
return interfaces.map(iface => {
|
||||
const zoneOptions = (iface.zones || []).map(z =>
|
||||
'<option value="' + escAttr(z) + '"' + (z === iface.zone ? ' selected' : '') + '>' + escHtml(z) + '</option>'
|
||||
).join('');
|
||||
return '<tr><td><strong>' + escHtml(iface.name) + '</strong></td>' +
|
||||
'<td class="text-muted">' + escHtml(iface.mac || 'N/A') + '</td>' +
|
||||
'<td>' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '</td>' +
|
||||
'<td><span class="status-dot ' + (iface.state === 'up' ? 'status-up' : 'status-down') + '"></span>' +
|
||||
(iface.state === 'up' ? 'Up' : 'Down') + '</td>' +
|
||||
'<td><select hx-on::change="assignZone(\'' + escAttr(iface.name) + '\', this)">' + zoneOptions + '</select></td></tr>';
|
||||
}).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,'<').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');
|
||||
/* ── Sidebar component ─────────────────────────────────────── */
|
||||
function Sidebar() {
|
||||
const current = router.state.path;
|
||||
return h('div', { class: 'sidebar' },
|
||||
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
||||
h('nav', null,
|
||||
Nav.map(item =>
|
||||
Link({
|
||||
path: item.path,
|
||||
class: current === item.path ? 'active' : '',
|
||||
children: [item.label],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
/* ── Route component wrapper ───────────────────────────────── */
|
||||
function RouteComponent() {
|
||||
return router.component();
|
||||
}
|
||||
|
||||
function validateCertIssue() {
|
||||
const domain = document.getElementById('cert-domain').value.trim();
|
||||
if (!domain) {
|
||||
showErrorToast('Domain is required');
|
||||
return;
|
||||
/* ── App layout ────────────────────────────────────────────── */
|
||||
function AppLayout() {
|
||||
return [
|
||||
h('div', { class: 'layout' },
|
||||
Sidebar(),
|
||||
h('div', { class: 'main' },
|
||||
RouteComponent(),
|
||||
),
|
||||
),
|
||||
ToastContainer(),
|
||||
];
|
||||
}
|
||||
|
||||
/* ── Init ──────────────────────────────────────────────────── */
|
||||
export function initApp() {
|
||||
const appEl = document.getElementById('app');
|
||||
if (appEl) {
|
||||
render(appEl, AppLayout);
|
||||
}
|
||||
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);
|
||||
});
|
||||
// Defer connect() after the first render microtask settles to prevent
|
||||
// the initial requestUpdate() from triggering a second commit while
|
||||
// the vnode tree is still being finalized.
|
||||
setTimeout(connect, 0);
|
||||
}
|
||||
|
||||
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 '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<span class="badge ' + badge + '">' + icon + '</span>' +
|
||||
'<span>' + escHtml(c.name).replace(/_/g, ' ') + '</span>' +
|
||||
'<span class="text-muted" style="flex:1;text-align:right;">' + escHtml(c.message || '') + '</span>' +
|
||||
'</div>';
|
||||
}).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 = '<div class="text-muted text-sm" style="margin:16px 0;">Starting certificate issuance…</div>';
|
||||
|
||||
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 = '<div class="text-muted text-sm">Pending…</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = steps.map(s => {
|
||||
let icon;
|
||||
if (s.status === 'done') icon = '<span class="status-dot status-up"></span>';
|
||||
else if (s.status === 'running') icon = '<span class="status-dot status-pending"></span>';
|
||||
else if (s.status === 'error') icon = '<span class="status-dot status-down"></span>';
|
||||
else icon = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border);margin-right:6px;"></span>';
|
||||
|
||||
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">' +
|
||||
icon +
|
||||
'<span>' + escHtml(s.label) + '</span>' +
|
||||
(s.status === 'running' ? '<span class="text-muted text-sm">(in progress…)</span>' :
|
||||
s.status === 'error' ? '<span class="badge badge-danger" style="margin-left:auto;">' + escHtml(s.message || 'failed') + '</span>' :
|
||||
'<span class="badge badge-success" style="margin-left:auto;">done</span>') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
if (status === 'completed') {
|
||||
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 = '<input type="text" class="route-dest" placeholder="Destination CIDR" style="flex:1;" />' +
|
||||
'<input type="text" class="route-gw" placeholder="Gateway" style="flex:1;" />' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button>';
|
||||
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) =>
|
||||
'<div class="route-row" style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">' +
|
||||
'<input type="text" class="route-dest" value="' + safe(r.destination) + '" placeholder="Destination CIDR" style="flex:1;" />' +
|
||||
'<input type="text" class="route-gw" value="' + safe(r.gateway) + '" placeholder="Gateway" style="flex:1;" />' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button></div>'
|
||||
).join('') || '<div class="text-muted text-sm">No static routes</div>';
|
||||
};
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
|
||||
Reference in New Issue
Block a user