Files
vacuum-wall/webui/static/app.js
T
mteehan 7beba44b4b feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)
- 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)
2026-05-30 05:45:40 +00:00

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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
};
// ─── 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>';
}
}