certs: async renewal via background task + status polling

- POST /acme/renew returns a request_id and spawns a background task
  (renew/deploy/refresh steps); dedups per-domain like issue
- completes as "skipped" when acme.sh reports the renewal window
  has not been reached (no --force)
- new GET /acme/renew/status endpoint (iface + handler + blueprint)
- run acme.sh subprocesses off the event loop (asyncio.to_thread)
  in both issuance and renewal
- ActionCell: busy/busyLabel props; certs page disables the Renew
  button and polls renewal status with toasts for success/skip/fail
This commit is contained in:
2026-08-20 13:10:16 +00:00
parent 332d14e37d
commit 94705490b9
9 changed files with 462 additions and 44 deletions
+58 -6
View File
@@ -1,6 +1,61 @@
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js';
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction, requestUpdate } from '/static/hoover/index.js';
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js';
// Domains with an in-flight renewal (button disabled while pending).
const _renewInFlight = new Set();
function renewCert(domain) {
if (_renewInFlight.has(domain)) return;
_renewInFlight.add(domain);
requestUpdate();
const done = () => {
if (_renewInFlight.delete(domain)) requestUpdate();
};
apiFetch('/api/certs/' + enc(domain) + '/renew', { method: 'POST' }).then(resp => {
if (!resp.ok) {
done();
toast(resp.error || 'Renewal failed', 'error');
return;
}
const rid = resp.data?.request_id;
if (!rid) { done(); toast('Renewal not started', 'error'); return; }
if (resp.data?.status === 'existing') {
toast('Renewal already in progress for ' + domain, 'warning');
} else {
toast('Renewal started for ' + domain, 'success');
}
poll({
url: '/api/certs/renew/' + enc(rid),
successKey: (d) => d.status === 'completed',
onErrorKey: (d) => d.status === 'failed' || d.status === 'skipped',
timeout: 180000,
onComplete: () => {
done();
toast('Certificate renewed for ' + domain, 'success');
},
onError: (d) => {
done();
if (d && d.status === 'skipped') {
toast('Certificate still valid — renewal skipped for ' + domain, 'info');
return;
}
let msg = (d && d.error) || 'unknown';
if (d == null) msg = 'timed out waiting for renewal';
else if (d.steps) {
const failed = d.steps.find(s => s.status === 'error');
if (failed && failed.message) msg = failed.message;
}
toast('Renewal failed for ' + domain + ': ' + msg, 'error');
},
});
}).catch((err) => {
done();
toast(err?.message || 'Renewal failed', 'error');
});
}
function _accountCard(account) {
if (!account || !account.registered) {
return html`<div class="card">
@@ -291,11 +346,8 @@ export default definePage({
<td>${badge}</td>
<${ActionCell}
editLabel="Renew"
editClick=${async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error');
}}
editClick=${() => renewCert(c.domain)}
busy=${_renewInFlight.has(c.domain)}
removeUrl=${'/api/certs/' + enc(c.domain)}
removeMessage=${'Remove certificate for ' + c.domain + '?'}
removeSuccess="Certificate removed"