7beba44b4b
- Add lib/state.py: in-memory state store with subsystem collectors (firewall, dnsmasq, nginx, acme, wireguard) - Refactor all handlers: read from state on GET, call refresh_state() after mutations instead of invoking subprocesses per request - daemon/server.py: add refresh_state(), /status/all, /status/refresh; populate state at startup - webui/api/certs.py: async step-by-step ACME issuance (validate, issue with request_id, poll status) replacing blocking endpoint - webui/server.py: render pages from state instead of direct lib calls - Update templates, JS for async cert issuance with polling UI - Update tests for state-based mocking; add test_state.py - Fix SIM105 lint issue (contextlib.suppress) - Add TODO.md with certificate issuance issue tracking Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
498 lines
25 KiB
JavaScript
498 lines
25 KiB
JavaScript
// 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
|
|
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 '<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');
|
|
}
|
|
|
|
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 '<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>';
|
|
}
|
|
}
|