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
+38 -3
View File
@@ -1333,11 +1333,46 @@ Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to
POST /api/certs/<domain>/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/<request_id>`.
**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/<request_id>
```
Poll the status of a certificate renewal started by
`POST /api/certs/<domain>/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.
---
+2
View File
@@ -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)`