From 94705490b98e60d65ade3cfa07c60df8d18fdcec Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Thu, 20 Aug 2026 13:10:16 +0000 Subject: [PATCH] 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 --- daemon/handlers/acme.py | 153 +++++++++++++++++++++---- daemon/iface.py | 1 + docs/api.md | 41 ++++++- docs/hoover.md | 2 + tests/test_api.py | 58 ++++++++++ tests/test_handler_acme.py | 140 +++++++++++++++++++++- webui/api/certs.py | 40 +++++-- webui/static/hoover/components/data.js | 7 +- webui/static/pages/certs.js | 64 ++++++++++- 9 files changed, 462 insertions(+), 44 deletions(-) diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index 938d63d..6db60a6 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -26,6 +26,7 @@ from daemon.iface import ( GET_ACME_ISSUE_STATUS, GET_ACME_LIST, GET_ACME_PATHS, + GET_ACME_RENEW_STATUS, POST_ACME_ACCOUNT_REGISTER, POST_ACME_EMAIL, POST_ACME_ISSUE, @@ -174,6 +175,21 @@ def _clean_expired_issuances() -> None: del _ISSUANCES[rid] +def _fail_op(req: IssueRequest, exc: Exception) -> None: + """Record the error on the first running step and mark the request failed.""" + for step in req.steps: + if step.status == "running": + step.status = "error" + step.message = str(exc) + break + else: + req.steps.append( + IssueStep(name="error", label="Error", status="error", message=str(exc)) + ) + req.status = "failed" + req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL + + # --------------------------------------------------------------------------- # Validation helpers @@ -821,13 +837,17 @@ async def _run_issue(req: IssueRequest) -> None: if account_email: args.extend(["-m", account_email]) args.append("--force") - output = _run_acme(args) + # acme.sh is a blocking subprocess — run it off the event loop so + # polling, WS broadcasts, and other requests keep responding. + output = await asyncio.to_thread(_run_acme, args) req.steps[0].status = "done" req.steps[0].message = output.strip()[:200] # Step 2: deploy req.steps[1].status = "running" - _run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]) + await asyncio.to_thread( + _run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK] + ) req.steps[1].status = "done" req.steps[1].message = "Deploy hook registered" @@ -844,17 +864,7 @@ async def _run_issue(req: IssueRequest) -> None: ) except Exception as exc: # Mark current running step as error, overall as failed - for step in req.steps: - if step.status == "running": - step.status = "error" - step.message = str(exc) - break - else: - req.steps.append( - IssueStep(name="error", label="Error", status="error", message=str(exc)) - ) - req.status = "failed" - req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL + _fail_op(req, exc) logger.error("Cert issuance for %s failed: %s", req.domain, exc) finally: _ISSUANCE_TASKS.pop(req.request_id, None) @@ -862,28 +872,123 @@ async def _run_issue(req: IssueRequest) -> None: @registry.register(POST_ACME_RENEW) def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: - """POST /acme/renew — renew a certificate for the given domain. + """POST /acme/renew — start a certificate renewal request (async). + + Deduplicates in-progress requests per domain. Spawns a background task + for the actual renewal. When ``force`` is not set, acme.sh skips the + renewal if the certificate's renewal window has not been reached yet + (the request then completes with status "skipped"). Args: force: Force renewal regardless of expiry. + Returns: + A dictionary containing the renewal request id (and "existing" + status when a renewal for the domain is already running). + Raises: ValueError: When domain is missing. """ if not body: raise ValueError("Request body required") - domain = body.get("domain", "").strip() + domain = (body.get("domain") or "").strip() if not domain: raise ValueError("'domain' is required") - force = body.get("force", False) - args: list[str] = ["--renew", "-d", domain] - if force: - args.append("--force") - output = _run_acme(args) - _run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK]) - logger.info("Certificate for %s renewed", domain) - refresh_state(["acme"]) - return {"domain": domain, "output": output.strip()} + force = bool(body.get("force", False)) + + _clean_expired_issuances() + + # Dedup: if domain already has an active request, return it + existing = _find_issuance(domain) + if existing: + return { + "request_id": existing.request_id, + "domain": domain, + "status": "existing", + } + + request_id = uuid4().hex[:12] + steps = [ + IssueStep(name="renew", label="Renewing certificate"), + IssueStep(name="deploy", label="Registering deploy hook"), + IssueStep(name="refresh", label="Refreshing certificate state"), + ] + req = IssueRequest(request_id=request_id, domain=domain, steps=steps) + _ISSUANCES[request_id] = req + + # Spawn background task + _task = asyncio.create_task(_run_renew(req, force)) + _ISSUANCE_TASKS[request_id] = _task + + return {"request_id": request_id, "domain": domain} + + +@registry.register(GET_ACME_RENEW_STATUS) +def get_renew_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """GET /acme/renew/status — poll status of a renewal request. + + Raises: + ValueError: When id is missing. + NotFoundError: When request_id is unknown. + """ + request_id = (body or {}).get("id", "").strip() + if not request_id: + raise ValueError("'id' is required") + + req = _ISSUANCES.get(request_id) + if not req: + raise NotFoundError(f"Renewal request {request_id} not found") + + return req.to_dict() + + +async def _run_renew(req: IssueRequest, force: bool) -> None: + """Background task: renew the certificate, register the deploy hook, refresh state.""" + try: + # Step 1: renew (acme.sh skips when the cert's renewal window has not + # been reached unless force is set) + req.steps[0].status = "running" + args: list[str] = ["--renew", "-d", req.domain] + if force: + args.append("--force") + output = await asyncio.to_thread(_run_acme, args) + if "Skipping." in output: + req.steps[0].status = "done" + req.steps[0].message = "Renewal not yet due — skipped" + req.status = "skipped" + req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL + logger.info( + "Renewal for %s skipped (request %s)", req.domain, req.request_id + ) + return + + req.steps[0].status = "done" + req.steps[0].message = output.strip()[:200] + + # Step 2: deploy + req.steps[1].status = "running" + await asyncio.to_thread( + _run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK] + ) + req.steps[1].status = "done" + req.steps[1].message = "Deploy hook registered" + + # Step 3: refresh state + req.steps[2].status = "running" + refresh_state(["acme"]) + req.steps[2].status = "done" + req.steps[2].message = "State refreshed" + + req.status = "completed" + req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL + logger.info( + "Certificate for %s renewed (request %s)", req.domain, req.request_id + ) + except Exception as exc: + _fail_op(req, exc) + logger.error("Renewal for %s failed: %s", req.domain, exc) + finally: + _ISSUANCE_TASKS.pop(req.request_id, None) @registry.register(DELETE_ACME_REMOVE) diff --git a/daemon/iface.py b/daemon/iface.py index 940916c..6c50f20 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -121,6 +121,7 @@ POST_ACME_VALIDATE: Endpoint = _ep("POST", "/acme/validate") POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue") GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status") POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew") +GET_ACME_RENEW_STATUS: Endpoint = _ep("GET", "/acme/renew/status") DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove") POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email") GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email") diff --git a/docs/api.md b/docs/api.md index 7462b7c..cb7759d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1333,11 +1333,46 @@ Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to POST /api/certs//renew ``` -Force-renew an existing certificate. +Start an async certificate renewal for an existing certificate. The renewal +runs in the background and is polled via +`GET /api/certs/renew/`. -**Response:** `data` is `null` on success. +**Request Body:** none (domain is taken from the path). -Returns HTTP `404` if the certificate is not found. Returns HTTP `500` if renewal fails. +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `request_id` | `string` | Unique identifier for polling renewal status | +| `domain` | `string` | Domain being renewed | +| `status` | `string` | Only when a renewal for this domain is already in progress (`"existing"` — the existing `request_id` is returned) | + +The renewal is a **no-op** when the certificate's renewal window (default: +30 days before expiry) has not been reached — the request then completes with +`status: "skipped"`. + +Returns HTTP `400` if the domain is missing. Returns HTTP `500` when the +renewal cannot be started (e.g. daemon unreachable). + +--- + +#### Poll Certificate Renewal Status + +``` +GET /api/certs/renew/ +``` + +Poll the status of a certificate renewal started by +`POST /api/certs//renew`. + +**Response (`data`):** Renewal status object containing `request_id`, +`domain`, `status` (`"running"`, `"completed"`, `"skipped"`, or `"failed"`), +a `steps` array (each with per-step status and error message), and +timestamps. + +Returns HTTP `404` if the request ID is not found. The frontend uses +`poll()` to repeatedly fetch this endpoint until the renewal completes, +is skipped, or fails. --- diff --git a/docs/hoover.md b/docs/hoover.md index 88541ab..5bf04b0 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -1052,6 +1052,8 @@ ActionCell({ | `removeLabel` | Delete button label (default: `'Remove'`) | | `removeBody` | Optional JSON body to send with DELETE | | `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) | +| `busy` | When `true` the action button is disabled and shows `busyLabel` (use for in-flight operations). | +| `busyLabel` | Label shown while `busy` (default: `editLabel` + `'…'`) | | `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. | #### `certStatusBadge(props)` diff --git a/tests/test_api.py b/tests/test_api.py index 47e73b6..8ce9a7e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -530,6 +530,64 @@ class TestCertsIssue: assert resp.status_code == 400 +class TestCertsRenew: + @_ce("post") + def test_start_renew(self, mock_post, client): + mock_post.return_value = {"request_id": "abc123", "domain": "example.com"} + resp = client.post("/api/certs/example.com/renew") + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + assert data["data"]["request_id"] == "abc123" + args = mock_post.call_args.args + assert args[0] == ("POST", "/acme/renew") + assert args[1] == {"domain": "example.com"} + + @_ce("post") + def test_start_renew_rejected(self, mock_post, client): + from daemon.client import BadRequest + + mock_post.side_effect = BadRequest("'domain' is required") + resp = client.post("/api/certs/example.com/renew") + assert resp.status_code == 400 + assert resp.get_json()["ok"] is False + + @_ce("post") + def test_start_renew_runtime_error(self, mock_post, client): + mock_post.side_effect = RuntimeError("daemon unreachable") + resp = client.post("/api/certs/example.com/renew") + assert resp.status_code == 500 + assert resp.get_json()["ok"] is False + + +class TestCertsRenewStatus: + @_ce("get") + def test_success(self, mock_get, client): + mock_get.return_value = { + "request_id": "abc123", + "domain": "example.com", + "status": "completed", + "steps": [], + } + resp = client.get("/api/certs/renew/abc123") + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + assert data["data"]["status"] == "completed" + args = mock_get.call_args.args + assert args[0] == ("GET", "/acme/renew/status") + assert args[1] == {"id": "abc123"} + + @_ce("get") + def test_not_found(self, mock_get, client): + from daemon.client import NotFound + + mock_get.side_effect = NotFound("renewal request not found") + resp = client.get("/api/certs/renew/unknown") + assert resp.status_code == 404 + assert resp.get_json()["ok"] is False + + class TestCertsEmail: def test_missing_email(self, client): resp = client.post("/api/certs/email", json={}) diff --git a/tests/test_handler_acme.py b/tests/test_handler_acme.py index 9086d90..3c3ed28 100644 --- a/tests/test_handler_acme.py +++ b/tests/test_handler_acme.py @@ -7,7 +7,9 @@ from unittest.mock import MagicMock, patch import pytest +import daemon.handlers.acme as acme_mod from daemon.handlers.acme import ( + IssueRequest, _check_account_registered, _check_acme_account, _check_acme_home_writable, @@ -25,10 +27,25 @@ from daemon.handlers.acme import ( deactivate_account, generate_self_signed, get_account, + get_renew_status, issue_cert, register_account, + renew_cert, ) -from daemon.server import ConflictError +from daemon.server import ConflictError, NotFoundError + + +def _await_renew(body): + """Start a renewal via renew_cert and await its background task.""" + + async def _run(): + result = renew_cert(None, body) + task = acme_mod._ISSUANCE_TASKS.get(result.get("request_id")) + if task is not None: + await task + return result + + return asyncio.run(_run()) class TestGenerateSelfSigned: @@ -1165,3 +1182,124 @@ class TestIssueCertExistingCerts: assert result["domain"] == "example.com" assert "request_id" in result mock_run_issue.assert_called_once() + + +class TestRenewCert: + def test_requires_body(self): + with pytest.raises(ValueError, match="Request body required"): + renew_cert(None, None) + + def test_requires_domain(self): + with pytest.raises(ValueError, match="'domain' is required"): + renew_cert(None, {"force": True}) + + def test_starts_background_task(self): + with ( + patch.dict("daemon.handlers.acme._ISSUANCES", clear=True), + patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True), + patch( + "daemon.handlers.acme._run_acme", return_value="Renewed 'example.com'" + ), + patch("daemon.handlers.acme.refresh_state") as mock_refresh, + ): + result = _await_renew({"domain": "example.com"}) + req = acme_mod._ISSUANCES[result["request_id"]] + + assert result["domain"] == "example.com" + assert "request_id" in result + assert req.status == "completed" + assert [s.status for s in req.steps] == ["done", "done", "done"] + mock_refresh.assert_called_once_with(["acme"]) + + def test_skip_when_not_due(self): + output = ( + "[Thu Aug 20 01:27:41 AM UTC 2026] Skipping. " + "Next renewal time is: 1785068261 (2026-07-26T12:17:41Z)" + ) + with ( + patch.dict("daemon.handlers.acme._ISSUANCES", clear=True), + patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True), + patch("daemon.handlers.acme._run_acme", return_value=output), + patch("daemon.handlers.acme.refresh_state") as mock_refresh, + ): + result = _await_renew({"domain": "example.com"}) + req = acme_mod._ISSUANCES[result["request_id"]] + + assert req.status == "skipped" + assert req.steps[0].status == "done" + mock_refresh.assert_not_called() + + def test_failure_marks_failed(self): + with ( + patch.dict("daemon.handlers.acme._ISSUANCES", clear=True), + patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True), + patch( + "daemon.handlers.acme._run_acme", + side_effect=RuntimeError("acme.sh failed with exit code 1: boom"), + ), + ): + result = _await_renew({"domain": "example.com"}) + req = acme_mod._ISSUANCES[result["request_id"]] + + assert req.status == "failed" + assert req.steps[0].status == "error" + assert "boom" in req.steps[0].message + + def test_force_appends_flag(self): + with ( + patch.dict("daemon.handlers.acme._ISSUANCES", clear=True), + patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True), + patch("daemon.handlers.acme._run_acme", return_value="ok") as mock_acme, + patch("daemon.handlers.acme.refresh_state"), + ): + _await_renew({"domain": "example.com", "force": True}) + + renew_args = mock_acme.call_args_list[0].args[0] + assert "--force" in renew_args + + def test_dedup_running_returns_existing(self): + existing = IssueRequest(request_id="existing123", domain="example.com") + with ( + patch.dict("daemon.handlers.acme._ISSUANCES", clear=True), + patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True), + patch("daemon.handlers.acme._run_acme") as mock_acme, + ): + acme_mod._ISSUANCES["existing123"] = existing + result = renew_cert(None, {"domain": "example.com"}) + + assert result == { + "request_id": "existing123", + "domain": "example.com", + "status": "existing", + } + mock_acme.assert_not_called() + + +class TestGetRenewStatus: + def test_missing_id(self): + with pytest.raises(ValueError, match="'id' is required"): + get_renew_status(None, None) + with pytest.raises(ValueError, match="'id' is required"): + get_renew_status(None, {}) + + def test_unknown_id(self): + with ( + patch.dict("daemon.handlers.acme._ISSUANCES", clear=True), + pytest.raises(NotFoundError, match="not found"), + ): + get_renew_status(None, {"id": "unknown"}) + + def test_returns_request_dict(self): + with ( + patch.dict("daemon.handlers.acme._ISSUANCES", clear=True), + patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True), + patch("daemon.handlers.acme._run_acme", return_value="Renewed"), + patch("daemon.handlers.acme.refresh_state"), + ): + result = _await_renew({"domain": "example.com"}) + status = get_renew_status(None, {"id": result["request_id"]}) + + assert status["request_id"] == result["request_id"] + assert status["domain"] == "example.com" + assert status["status"] == "completed" + assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"] diff --git a/webui/api/certs.py b/webui/api/certs.py index b02ba16..e9060b7 100644 --- a/webui/api/certs.py +++ b/webui/api/certs.py @@ -15,6 +15,7 @@ from daemon.iface import ( GET_ACME_INFO, GET_ACME_ISSUE_STATUS, GET_ACME_LIST, + GET_ACME_RENEW_STATUS, POST_ACME_ACCOUNT_REGISTER, POST_ACME_EMAIL, POST_ACME_ISSUE, @@ -144,19 +145,21 @@ def issue_status(request_id: str): @bp.route("//renew", methods=["POST"]) def renew_bp(domain: str): - """POST /api/certs//renew — renew an existing certificate. - - Args: - domain: Domain name whose certificate should be renewed. + """POST /api/certs//renew — start an (async) certificate renewal. Returns: - Response confirming renewal or an error message. + Response containing a renewal request ID (poll it at + ``/api/certs/renew/``) or an error message. """ try: logger.info("Certificate renewal requested for '%s' via API", domain) - post(POST_ACME_RENEW, {"domain": domain}) - logger.info("Certificate renewed for '%s'", domain) - return _ok(None) + result = post(POST_ACME_RENEW, {"domain": domain}) + logger.info( + "Certificate renewal started for '%s' (id=%s)", + domain, + result.get("request_id"), + ) + return _ok(result) except BadRequest as exc: logger.info("Cert renew for '%s' rejected: %s", domain, exc) return _error(str(exc), 400) @@ -165,6 +168,27 @@ def renew_bp(domain: str): return _error(str(exc), 500) +@bp.route("/renew/", methods=["GET"]) +def renew_status(request_id: str): + """GET /api/certs/renew/ — poll status of a certificate renewal. + + Args: + request_id: Renewal request identifier returned by renew_bp. + + Returns: + Response containing renewal status or an error message. + """ + try: + result = get(GET_ACME_RENEW_STATUS, {"id": request_id}) + return _ok(result) + except NotFound as exc: + logger.info("Renewal request '%s' not found: %s", request_id, exc) + return _error(str(exc), 404) + except RuntimeError as exc: + logger.error("Failed to get renewal status for '%s': %s", request_id, exc) + return _error(str(exc), 500) + + @bp.route("/", methods=["DELETE"]) def remove_bp(domain: str): """DELETE /api/certs/ — remove a certificate from ACME management. diff --git a/webui/static/hoover/components/data.js b/webui/static/hoover/components/data.js index 9b51eb9..f5d1389 100644 --- a/webui/static/hoover/components/data.js +++ b/webui/static/hoover/components/data.js @@ -314,6 +314,8 @@ export function ServiceStatus(props = {}) { * @param {string} [props.removeLabel] - Delete button label (default: 'Remove') * @param {object} [props.removeBody] - Optional JSON body to send with DELETE * @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline') + * @param {boolean} [props.busy] - When true the action button is disabled (in-flight operation) + * @param {string} [props.busyLabel] - Label shown while busy (default: editLabel + '…') * @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling */ export function ActionCell(props = {}) { @@ -321,8 +323,9 @@ export function ActionCell(props = {}) { h('button', { class: props.editCls || 'btn btn-sm btn-outline', style: 'margin-right:4px;', - 'on:click': props.editClick, - }, props.editLabel), + disabled: !!props.busy, + 'on:click': props.busy ? undefined : props.editClick, + }, props.busy ? (props.busyLabel || (props.editLabel + '…')) : props.editLabel), ConfirmDelete({ url: props.removeUrl, message: props.removeMessage, diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index e740d03..b89571f 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -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`
@@ -291,11 +346,8 @@ export default definePage({ ${badge} <${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"