BREAKING CHANGE(certs): Introduce domain-centric certificate provisioning with per-domain exponential backoff and a staggered serial scheduler; add domain-based reprovision API and UI backoff display; change certificate overview API to be domain-first and include backoff info; bump related deps.

This commit is contained in:
2026-02-15 16:03:13 +00:00
parent 2d44528345
commit 8e9de46cd2
11 changed files with 529 additions and 182 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 2026-02-15 - 6.0.0 - BREAKING CHANGE(certs)
Introduce domain-centric certificate provisioning with per-domain exponential backoff and a staggered serial scheduler; add domain-based reprovision API and UI backoff display; change certificate overview API to be domain-first and include backoff info; bump related deps.
- Add CertProvisionScheduler: persistent per-domain exponential backoff, retry calculation, and an in-memory serial stagger queue.
- Integrate scheduler with SmartAcme certProvisionFunction: enqueue provisions, clear backoff on success, record failures to drive backoff.
- Switch certificate event tracking to be keyed by domain (certificateStatusMap now keyed by domain) and add findRouteNamesForDomain helper.
- BREAKING: ICertificateInfo shape changed — replaced routeName/domains with domain and routeNames; added optional backoffInfo (failures, retryAfter, lastError).
- Add domain-based reprovision endpoint (reprovisionCertificateDomain) while retaining legacy route-based reprovision for backward compatibility (internal rename to reprovisionCertificateByRoute).
- Web UI updated to domain-centric certificate overview, displays route pills, backoff indicator and retry timing, and uses domain-based reprovision action.
- Dependency bumps: @push.rocks/smartlog -> ^3.1.11, @push.rocks/smartproxy -> ^25.3.1.
## 2026-02-14 - 5.5.0 - feat(certs)
persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting

View File

@@ -42,14 +42,14 @@
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartlog": "^3.1.11",
"@push.rocks/smartmetrics": "^2.0.10",
"@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.2.2",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.3.0",
"@push.rocks/smartproxy": "^25.3.1",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",

129
pnpm-lock.yaml generated
View File

@@ -37,7 +37,7 @@ importers:
version: 6.1.3
'@push.rocks/smartacme':
specifier: ^8.0.0
version: 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
version: 8.0.0(socks@2.8.7)
'@push.rocks/smartdata':
specifier: ^7.0.15
version: 7.0.15(socks@2.8.7)
@@ -54,8 +54,8 @@ importers:
specifier: ^2.2.1
version: 2.2.1
'@push.rocks/smartlog':
specifier: ^3.1.10
version: 3.1.10
specifier: ^3.1.11
version: 3.1.11
'@push.rocks/smartmetrics':
specifier: ^2.0.10
version: 2.0.10
@@ -75,8 +75,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^25.3.0
version: 25.3.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
specifier: ^25.3.1
version: 25.3.1
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -116,7 +116,7 @@ importers:
version: 2.0.1
'@git.zone/tstest':
specifier: ^3.1.8
version: 3.1.8(socks@2.8.7)(typescript@5.9.3)
version: 3.1.8(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
'@git.zone/tswatch':
specifier: ^3.1.0
version: 3.1.0(@tiptap/pm@2.27.2)
@@ -970,8 +970,8 @@ packages:
'@push.rocks/smartlog-interfaces@3.0.2':
resolution: {integrity: sha512-8hGRTJehbsFSJxLhCQkA018mZtXVPxPTblbg9VaE/EqISRzUw+eosJ2EJV7M4Qu0eiTJZjnWnNLn8CkD77ziWw==}
'@push.rocks/smartlog@3.1.10':
resolution: {integrity: sha512-5pf5JyzOE2WTCUislNIW4EHePo1a7hiXB+jbil38+N5hW71AEwcPFe6oGxbp5w9ALlz66hV2+E+25R0SsxN+fQ==}
'@push.rocks/smartlog@3.1.11':
resolution: {integrity: sha512-zyLH8pQD2UD7l76wJBESEWXU1FSTBLOuRI0/DN139EYyMkwMq1+pdQKptTkJhhVL/OIj56oMg9SpJb4bJB7uKg==}
'@push.rocks/smartmail@2.2.0':
resolution: {integrity: sha512-28K4HAcda7ODUUpFCgbS/uA+eqwVRcmLJERIdM9AvLHXaHAPLHH97HmwPPcAu9Sp3z05Um0inmDF51X6yVVkcw==}
@@ -1040,8 +1040,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@25.3.0':
resolution: {integrity: sha512-ie0jP6dCSZFvrdRmlo5NTufA6AJeQdGsgVQv6M9okQ4IXBkm3LVN+u6t9T2nHalnopMJXLb+qAuq0Y2T5mxIJg==}
'@push.rocks/smartproxy@25.3.1':
resolution: {integrity: sha512-kGJGpx3KBUz+qWU2L9B2gbZoUbQEG2BFe6ZzK0b68Y32nHoSIMjol14hzc3sRgW1p/loWy+Gj+5j0KuVytKWmA==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1131,6 +1131,9 @@ packages:
'@push.rocks/webrequest@3.0.37':
resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==}
'@push.rocks/webrequest@4.0.1':
resolution: {integrity: sha512-I60XZZLVf8W5I7YdmUVVu4G92teE3rg3/aKaV00BRg8vJ3VXx3wc59Qj4em7zxQ5o0HvL8m1Aezw3RFMDPyVgA==}
'@push.rocks/webrequest@4.0.2':
resolution: {integrity: sha512-rowzty+Q2papFBcnNYPcy+8CQJukSn/FGfQG8ap0bUgQUsx882u8kEyLM0Q+GlGHS5OiZ+Z0z5TZqLKlk3XHxA==}
@@ -1847,10 +1850,6 @@ packages:
'@types/minimatch@5.1.2':
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
'@types/minimatch@6.0.0':
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
@@ -4379,7 +4378,7 @@ snapshots:
'@push.rocks/smartfeed': 1.4.0
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartlog-destination-devtools': 1.0.12
'@push.rocks/smartlog-interfaces': 3.0.2
'@push.rocks/smartmanifest': 2.0.2
@@ -4428,7 +4427,7 @@ snapshots:
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartlog-destination-devtools': 1.0.12
'@push.rocks/smartlog-interfaces': 3.0.2
'@push.rocks/smartmanifest': 2.0.2
@@ -4495,7 +4494,7 @@ snapshots:
'@apiclient.xyz/cloudflare@6.4.3':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartstring': 4.1.0
@@ -4507,7 +4506,7 @@ snapshots:
'@apiclient.xyz/cloudflare@7.1.0':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartstring': 4.1.0
@@ -5241,7 +5240,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
typescript: 5.9.3
@@ -5262,7 +5261,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartinteract': 2.0.16
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartlog-destination-local': 9.0.2
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
@@ -5288,7 +5287,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartnpm': 2.0.6
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrequest': 5.0.1
@@ -5308,7 +5307,7 @@ snapshots:
'@push.rocks/smartshell': 3.3.0
tsx: 4.21.0
'@git.zone/tstest@3.1.8(socks@2.8.7)(typescript@5.9.3)':
'@git.zone/tstest@3.1.8(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)':
dependencies:
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
'@git.zone/tsbundle': 2.8.3
@@ -5323,7 +5322,7 @@ snapshots:
'@push.rocks/smartexpect': 2.5.0
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
'@push.rocks/smartnetwork': 4.4.0
'@push.rocks/smartpath': 6.0.0
@@ -5339,6 +5338,7 @@ snapshots:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
- '@push.rocks/smartserve'
- '@swc/helpers'
- aws-crt
- bare-abort-controller
@@ -5368,7 +5368,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartinteract': 2.0.16
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartlog-destination-local': 9.0.2
'@push.rocks/smartshell': 3.3.0
'@push.rocks/smartwatch': 6.3.0
@@ -5805,7 +5805,7 @@ snapshots:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
@@ -5829,10 +5829,10 @@ snapshots:
'@api.global/typedrequest': 3.2.6
'@configvault.io/interfaces': 1.0.17
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartacme@8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)':
'@push.rocks/smartacme@8.0.0(socks@2.8.7)':
dependencies:
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
'@apiclient.xyz/cloudflare': 6.4.3
@@ -5841,7 +5841,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 6.2.2
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartnetwork': 4.4.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.1.0
@@ -5854,9 +5854,7 @@ snapshots:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
- '@push.rocks/smartserve'
- bare-abort-controller
- bufferutil
- encoding
- gcp-metadata
- kerberos
@@ -5866,7 +5864,6 @@ snapshots:
- snappy
- socks
- supports-color
- utf-8-validate
- vue
'@push.rocks/smartarchive@4.2.4':
@@ -5956,7 +5953,7 @@ snapshots:
'@push.rocks/smartcli@4.0.20':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartobject': 1.0.12
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
@@ -5981,7 +5978,7 @@ snapshots:
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
@@ -6010,7 +6007,7 @@ snapshots:
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
@@ -6218,7 +6215,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 2.0.2
'@tsclass/tsclass': 4.4.4
'@push.rocks/smartlog@3.1.10':
'@push.rocks/smartlog@3.1.11':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/consolecolor': 2.0.3
@@ -6228,7 +6225,7 @@ snapshots:
'@push.rocks/smarthash': 3.2.6
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smarttime': 4.1.1
'@push.rocks/webrequest': 3.0.37
'@push.rocks/webrequest': 4.0.1
'@tsclass/tsclass': 9.3.0
'@push.rocks/smartmail@2.2.0':
@@ -6265,7 +6262,7 @@ snapshots:
'@push.rocks/smartmetrics@2.0.10':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@types/pidusage': 2.0.5
pidtree: 0.6.0
pidusage: 4.0.1
@@ -6338,7 +6335,7 @@ snapshots:
dependencies:
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartmail': 2.2.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.2.1
@@ -6441,45 +6438,13 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@25.3.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)':
'@push.rocks/smartproxy@25.3.1':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartnetwork': 4.4.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartrust': 1.2.1
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstring': 4.1.0
'@push.rocks/taskbuffer': 4.2.0
'@tsclass/tsclass': 9.3.0
'@types/minimatch': 6.0.0
'@types/ws': 8.18.1
minimatch: 10.2.0
pretty-ms: 9.3.0
ws: 8.19.0
transitivePeerDependencies:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
- '@push.rocks/smartserve'
- bare-abort-controller
- bufferutil
- encoding
- gcp-metadata
- kerberos
- mongodb-client-encryption
- react
- react-native-b4a
- snappy
- socks
- supports-color
- utf-8-validate
- vue
'@push.rocks/smartpuppeteer@2.0.5(typescript@5.9.3)':
dependencies:
@@ -6557,7 +6522,7 @@ snapshots:
'@cfworker/json-schema': 4.1.1
'@push.rocks/lik': 6.2.2
'@push.rocks/smartenv': 6.0.0
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpath': 6.0.0
ws: 8.19.0
transitivePeerDependencies:
@@ -6592,7 +6557,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartenv': 5.0.13
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1
@@ -6696,7 +6661,7 @@ snapshots:
'@design.estate/dees-element': 2.1.6
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1
@@ -6712,7 +6677,7 @@ snapshots:
'@design.estate/dees-element': 2.1.6
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1
@@ -6731,6 +6696,14 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/webstore': 2.0.20
'@push.rocks/webrequest@4.0.1':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartenv': 5.0.13
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/webstore': 2.0.20
'@push.rocks/webrequest@4.0.2':
dependencies:
'@push.rocks/smartdelay': 3.0.5
@@ -7570,10 +7543,6 @@ snapshots:
'@types/minimatch@5.1.2': {}
'@types/minimatch@6.0.0':
dependencies:
minimatch: 10.2.0
'@types/ms@2.1.0': {}
'@types/mute-stream@0.0.4':

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '5.5.0',
version: '6.0.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -0,0 +1,176 @@
import { logger } from './logger.js';
import type { StorageManager } from './storage/index.js';
interface IBackoffEntry {
failures: number;
lastFailure: string; // ISO string
retryAfter: string; // ISO string
lastError?: string;
}
/**
* Manages certificate provisioning scheduling with:
* - Per-domain exponential backoff persisted in StorageManager
* - Serial stagger queue with configurable delay between provisions
*/
export class CertProvisionScheduler {
private storageManager: StorageManager;
private staggerDelayMs: number;
private maxBackoffHours: number;
// In-memory serial queue
private queue: Array<{
domain: string;
fn: () => Promise<any>;
resolve: (value: any) => void;
reject: (err: any) => void;
}> = [];
private processing = false;
// In-memory backoff cache (mirrors storage for fast lookups)
private backoffCache = new Map<string, IBackoffEntry>();
constructor(
storageManager: StorageManager,
options?: { staggerDelayMs?: number; maxBackoffHours?: number }
) {
this.storageManager = storageManager;
this.staggerDelayMs = options?.staggerDelayMs ?? 3000;
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
}
/**
* Storage key for a domain's backoff entry
*/
private backoffKey(domain: string): string {
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
return `/cert-backoff/${clean}`;
}
/**
* Load backoff entry from storage (with in-memory cache)
*/
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
const cached = this.backoffCache.get(domain);
if (cached) return cached;
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
if (entry) {
this.backoffCache.set(domain, entry);
}
return entry;
}
/**
* Save backoff entry to both cache and storage
*/
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
this.backoffCache.set(domain, entry);
await this.storageManager.setJSON(this.backoffKey(domain), entry);
}
/**
* Check if a domain is currently in backoff
*/
async isInBackoff(domain: string): Promise<boolean> {
const entry = await this.loadBackoff(domain);
if (!entry) return false;
const retryAfter = new Date(entry.retryAfter);
return retryAfter.getTime() > Date.now();
}
/**
* Record a provisioning failure for a domain.
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
*/
async recordFailure(domain: string, error?: string): Promise<void> {
const existing = await this.loadBackoff(domain);
const failures = (existing?.failures ?? 0) + 1;
// Exponential backoff: failures^2 hours, capped
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
const entry: IBackoffEntry = {
failures,
lastFailure: new Date().toISOString(),
retryAfter: retryAfter.toISOString(),
lastError: error,
};
await this.saveBackoff(domain, entry);
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
}
/**
* Clear backoff for a domain (on success or manual override)
*/
async clearBackoff(domain: string): Promise<void> {
this.backoffCache.delete(domain);
try {
await this.storageManager.delete(this.backoffKey(domain));
} catch {
// Ignore delete errors (key may not exist)
}
}
/**
* Get backoff info for UI display
*/
async getBackoffInfo(domain: string): Promise<{
failures: number;
retryAfter?: string;
lastError?: string;
} | null> {
const entry = await this.loadBackoff(domain);
if (!entry) return null;
// Only return if still in backoff
const retryAfter = new Date(entry.retryAfter);
if (retryAfter.getTime() <= Date.now()) return null;
return {
failures: entry.failures,
retryAfter: entry.retryAfter,
lastError: entry.lastError,
};
}
/**
* Enqueue a provision operation for serial execution with stagger delay.
* Returns the result of the provision function.
*/
enqueueProvision<T>(domain: string, fn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ domain, fn, resolve, reject });
this.processQueue();
});
}
/**
* Process the stagger queue serially
*/
private async processQueue(): Promise<void> {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
const item = this.queue.shift()!;
try {
logger.log('info', `Processing cert provision for ${item.domain}`);
const result = await item.fn();
item.resolve(result);
} catch (err) {
item.reject(err);
}
// Stagger delay between provisions
if (this.queue.length > 0) {
await new Promise<void>((r) => setTimeout(r, this.staggerDelayMs));
}
}
this.processing = false;
}
}

View File

@@ -14,6 +14,7 @@ import { logger } from './logger.js';
// Import storage manager
import { StorageManager, type IStorageConfig } from './storage/index.js';
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
// Import cache system
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
@@ -184,16 +185,19 @@ export class DcRouter {
public cacheDb?: CacheDb;
public cacheCleaner?: CacheCleaner;
// Certificate status tracking from SmartProxy events
// Certificate status tracking from SmartProxy events (keyed by domain)
public certificateStatusMap = new Map<string, {
status: 'valid' | 'failed';
domain: string;
routeNames: string[];
expiryDate?: string;
issuedAt?: string;
source?: string;
error?: string;
}>();
// Certificate provisioning scheduler with backoff + stagger
public certProvisionScheduler?: CertProvisionScheduler;
// TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -467,6 +471,9 @@ export class DcRouter {
},
};
// Initialize cert provision scheduler
this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager);
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
if (challengeHandlers.length > 0) {
this.smartAcme = new plugins.smartacme.SmartAcme({
@@ -478,24 +485,41 @@ export class DcRouter {
});
await this.smartAcme.start();
const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
// Check backoff before attempting provision
if (await scheduler.isInBackoff(domain)) {
const info = await scheduler.getBackoffInfo(domain);
const msg = `Domain ${domain} is in backoff (${info?.failures} failures), retry after ${info?.retryAfter}`;
eventComms.warn(msg);
throw new Error(msg);
}
try {
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
eventComms.setSource('smartacme-dns-01');
const cert = await this.smartAcme.getCertificateForDomain(domain);
if (cert.validUntil) {
eventComms.setExpiryDate(new Date(cert.validUntil));
}
return {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
validUntil: cert.validUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr,
};
const result = await scheduler.enqueueProvision(domain, async () => {
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
eventComms.setSource('smartacme-dns-01');
const cert = await this.smartAcme.getCertificateForDomain(domain);
if (cert.validUntil) {
eventComms.setExpiryDate(new Date(cert.validUntil));
}
return {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
validUntil: cert.validUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr,
};
});
// Success — clear any backoff
await scheduler.clearBackoff(domain);
return result;
} catch (err) {
// Record failure for backoff tracking
await scheduler.recordFailure(domain, err.message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`);
return 'http01';
}
@@ -519,39 +543,34 @@ export class DcRouter {
});
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
// Events are keyed by domain for domain-centric certificate tracking
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
console.log(`[DcRouter] Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
const routeName = this.findRouteNameForDomain(event.domain);
if (routeName) {
this.certificateStatusMap.set(routeName, {
status: 'valid', domain: event.domain,
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
source: event.source,
});
}
const routeNames = this.findRouteNamesForDomain(event.domain);
this.certificateStatusMap.set(event.domain, {
status: 'valid', routeNames,
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
source: event.source,
});
});
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
console.log(`[DcRouter] Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
const routeName = this.findRouteNameForDomain(event.domain);
if (routeName) {
this.certificateStatusMap.set(routeName, {
status: 'valid', domain: event.domain,
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
source: event.source,
});
}
const routeNames = this.findRouteNamesForDomain(event.domain);
this.certificateStatusMap.set(event.domain, {
status: 'valid', routeNames,
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
source: event.source,
});
});
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
console.error(`[DcRouter] Certificate failed for ${event.domain} (${event.source}):`, event.error);
const routeName = this.findRouteNameForDomain(event.domain);
if (routeName) {
this.certificateStatusMap.set(routeName, {
status: 'failed', domain: event.domain, error: event.error,
source: event.source,
});
}
const routeNames = this.findRouteNamesForDomain(event.domain);
this.certificateStatusMap.set(event.domain, {
status: 'failed', routeNames, error: event.error,
source: event.source,
});
});
// Start SmartProxy
@@ -724,7 +743,7 @@ export class DcRouter {
}
/**
* Find the route name that matches a given domain
* Find the first route name that matches a given domain
*/
private findRouteNameForDomain(domain: string): string | undefined {
if (!this.smartProxy) return undefined;
@@ -740,6 +759,27 @@ export class DcRouter {
return undefined;
}
/**
* Find ALL route names that match a given domain
*/
public findRouteNamesForDomain(domain: string): string[] {
if (!this.smartProxy) return [];
const names: string[] = [];
for (const route of this.smartProxy.routeManager.getRoutes()) {
if (!route.match.domains || !route.name) continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const pattern of routeDomains) {
if (this.isDomainMatch(domain, pattern)) {
names.push(route.name);
break; // This route already matched, no need to check other patterns
}
}
}
return names;
}
public async stop() {
console.log('Stopping DcRouter services...');

View File

@@ -23,24 +23,45 @@ export class CertificateHandler {
)
);
// Reprovision Certificate
// Legacy route-based reprovision (backward compat)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
return this.reprovisionCertificate(dataArg.routeName);
return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
return this.reprovisionCertificateDomain(dataArg.domain);
}
)
);
}
/**
* Build domain-centric certificate overview.
* Instead of one row per route, we produce one row per unique domain.
*/
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) return [];
const routes = smartProxy.routeManager.getRoutes();
const certificates: interfaces.requests.ICertificateInfo[] = [];
// Phase 1: Collect unique domains with their associated route info
const domainMap = new Map<string, {
routeNames: string[];
source: interfaces.requests.TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
canReprovision: boolean;
}>();
for (const route of routes) {
if (!route.name) continue;
@@ -58,7 +79,6 @@ export class CertificateHandler {
// Determine source
let source: interfaces.requests.TCertificateSource = 'none';
if (tls.certificate === 'auto') {
// Check if a certProvisionFunction is configured
if ((smartProxy.settings as any).certProvisionFunction) {
source = 'provision-function';
} else {
@@ -68,15 +88,44 @@ export class CertificateHandler {
source = 'static';
}
// Start with unknown status
const canReprovision = source === 'acme' || source === 'provision-function';
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
for (const domain of routeDomains) {
const existing = domainMap.get(domain);
if (existing) {
// Add this route name to the existing domain entry
if (!existing.routeNames.includes(route.name)) {
existing.routeNames.push(route.name);
}
// Upgrade source if more specific
if (existing.source === 'none' && source !== 'none') {
existing.source = source;
existing.canReprovision = canReprovision;
}
} else {
domainMap.set(domain, {
routeNames: [route.name],
source,
tlsMode,
canReprovision,
});
}
}
}
// Phase 2: Resolve status for each unique domain
const certificates: interfaces.requests.ICertificateInfo[] = [];
for (const [domain, info] of domainMap) {
let status: interfaces.requests.TCertificateStatus = 'unknown';
let expiryDate: string | undefined;
let issuedAt: string | undefined;
let issuer: string | undefined;
let error: string | undefined;
// Check event-based status from DcRouter's certificateStatusMap
const eventStatus = dcRouter.certificateStatusMap.get(route.name);
// Check event-based status from certificateStatusMap (now keyed by domain)
const eventStatus = dcRouter.certificateStatusMap.get(domain);
if (eventStatus) {
status = eventStatus.status;
expiryDate = eventStatus.expiryDate;
@@ -87,10 +136,10 @@ export class CertificateHandler {
}
}
// Try Rust-side certificate status if no event data
if (status === 'unknown') {
// Try SmartProxy certificate status if no event data
if (status === 'unknown' && info.routeNames.length > 0) {
try {
const rustStatus = await smartProxy.getCertificateStatus(route.name);
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
if (rustStatus) {
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
if (rustStatus.issuer) issuer = rustStatus.issuer;
@@ -105,22 +154,19 @@ export class CertificateHandler {
}
// Check persisted cert data from StorageManager
if (status === 'unknown' && routeDomains.length > 0) {
for (const domain of routeDomains) {
if (expiryDate) break;
const cleanDomain = domain.replace(/^\*\.?/, '');
const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certData?.validUntil) {
expiryDate = new Date(certData.validUntil).toISOString();
if (certData.created) {
issuedAt = new Date(certData.created).toISOString();
}
issuer = 'smartacme-dns-01';
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certData?.validUntil) {
expiryDate = new Date(certData.validUntil).toISOString();
if (certData.created) {
issuedAt = new Date(certData.created).toISOString();
}
issuer = 'smartacme-dns-01';
}
}
// Compute status from expiry date if we have one and status is still valid/unknown
// Compute status from expiry date
if (expiryDate && (status === 'valid' || status === 'unknown')) {
const expiry = new Date(expiryDate);
const now = new Date();
@@ -136,28 +182,36 @@ export class CertificateHandler {
}
// Static certs with no other info default to 'valid'
if (source === 'static' && status === 'unknown') {
if (info.source === 'static' && status === 'unknown') {
status = 'valid';
}
// ACME/provision-function routes with no cert data are still provisioning
if (status === 'unknown' && (source === 'acme' || source === 'provision-function')) {
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
status = 'provisioning';
}
const canReprovision = source === 'acme' || source === 'provision-function';
// Phase 3: Attach backoff info
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
if (dcRouter.certProvisionScheduler) {
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
if (bi) {
backoffInfo = bi;
}
}
certificates.push({
routeName: route.name,
domains: routeDomains,
domain,
routeNames: info.routeNames,
status,
source,
tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough',
source: info.source,
tlsMode: info.tlsMode,
expiryDate,
issuer,
issuedAt,
error,
canReprovision,
canReprovision: info.canReprovision,
backoffInfo,
});
}
@@ -187,7 +241,10 @@ export class CertificateHandler {
return summary;
}
private async reprovisionCertificate(routeName: string): Promise<{ success: boolean; message?: string }> {
/**
* Legacy route-based reprovisioning
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
@@ -197,11 +254,58 @@ export class CertificateHandler {
try {
await smartProxy.provisionCertificate(routeName);
// Clear event-based status so it gets refreshed
dcRouter.certificateStatusMap.delete(routeName);
// Clear event-based status for domains in this route
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err) {
return { success: false, message: err.message || 'Failed to reprovision certificate' };
}
}
/**
* Domain-based reprovisioning — clears backoff first, then triggers provision
*/
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear backoff for this domain (user override)
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
// Clear status map entry so it gets refreshed
dcRouter.certificateStatusMap.delete(domain);
// Try to provision via SmartAcme directly
if (dcRouter.smartAcme) {
try {
await dcRouter.smartAcme.getCertificateForDomain(domain);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
}
}
// Fallback: try provisioning via the first matching route
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length > 0) {
try {
await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
} catch (err) {
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
}
}
return { success: false, message: `No routes found for domain '${domain}'` };
}
}

View File

@@ -5,8 +5,8 @@ export type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisionin
export type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
export interface ICertificateInfo {
routeName: string;
domains: string[];
domain: string;
routeNames: string[];
status: TCertificateStatus;
source: TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
@@ -15,6 +15,11 @@ export interface ICertificateInfo {
issuedAt?: string; // ISO string
error?: string; // if status === 'failed'
canReprovision: boolean; // true for acme/provision-function routes
backoffInfo?: {
failures: number;
retryAfter?: string; // ISO string
lastError?: string;
};
}
export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfaces.implementsTR<
@@ -38,6 +43,7 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
};
}
// Legacy route-based reprovision (kept for backward compat)
export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ReprovisionCertificate
@@ -52,3 +58,19 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa
message?: string;
};
}
// Domain-based reprovision (preferred)
export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ReprovisionCertificateDomain
> {
method: 'reprovisionCertificateDomain';
request: {
identity?: authInterfaces.IIdentity;
domain: string;
};
response: {
success: boolean;
message?: string;
};
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '5.5.0',
version: '6.0.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -719,18 +719,18 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
});
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
async (statePartArg, routeName) => {
async (statePartArg, domain) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ReprovisionCertificate
>('/typedrequest', 'reprovisionCertificate');
interfaces.requests.IReq_ReprovisionCertificateDomain
>('/typedrequest', 'reprovisionCertificateDomain');
await request.fire({
identity: context.identity,
routeName,
domain,
});
// Re-fetch overview after reprovisioning

View File

@@ -94,13 +94,13 @@ export class OpsViewCertificates extends DeesElement {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.domainPills {
.routePills {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.domainPill {
.routePill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
@@ -125,6 +125,17 @@ export class OpsViewCertificates extends DeesElement {
white-space: nowrap;
}
.backoffIndicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
padding: 2px 6px;
border-radius: 4px;
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
}
.expiryInfo {
font-size: 12px;
}
@@ -218,14 +229,16 @@ export class OpsViewCertificates extends DeesElement {
<dees-table
.data=${this.certState.certificates}
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
Route: cert.routeName,
Domains: this.renderDomainPills(cert.domains),
Domain: cert.domain,
Routes: this.renderRoutePills(cert.routeNames),
Status: this.renderStatusBadge(cert.status),
Source: this.renderSourceBadge(cert.source),
Expires: this.renderExpiry(cert.expiryDate),
Error: cert.error
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
: '',
Error: cert.backoffInfo
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
: cert.error
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
: '',
})}
.dataActions=${[
{
@@ -245,11 +258,11 @@ export class OpsViewCertificates extends DeesElement {
}
await appstate.certificateStatePart.dispatchAction(
appstate.reprovisionCertificateAction,
cert.routeName,
cert.domain,
);
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({
message: `Reprovisioning triggered for ${cert.routeName}`,
message: `Reprovisioning triggered for ${cert.domain}`,
type: 'success',
duration: 3000,
});
@@ -263,7 +276,7 @@ export class OpsViewCertificates extends DeesElement {
const cert = actionData.item;
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Certificate: ${cert.routeName}`,
heading: `Certificate: ${cert.domain}`,
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
@@ -275,10 +288,10 @@ export class OpsViewCertificates extends DeesElement {
`,
menuOptions: [
{
name: 'Copy Route Name',
name: 'Copy Domain',
iconName: 'copy',
action: async () => {
await navigator.clipboard.writeText(cert.routeName);
await navigator.clipboard.writeText(cert.domain);
},
},
],
@@ -287,7 +300,7 @@ export class OpsViewCertificates extends DeesElement {
},
]}
heading1="Certificate Status"
heading2="TLS certificates across all routes"
heading2="TLS certificates by domain"
searchable
.pagination=${true}
.paginationSize=${50}
@@ -296,14 +309,14 @@ export class OpsViewCertificates extends DeesElement {
`;
}
private renderDomainPills(domains: string[]): TemplateResult {
private renderRoutePills(routeNames: string[]): TemplateResult {
const maxShow = 3;
const visible = domains.slice(0, maxShow);
const remaining = domains.length - maxShow;
const visible = routeNames.slice(0, maxShow);
const remaining = routeNames.length - maxShow;
return html`
<span class="domainPills">
${visible.map((d) => html`<span class="domainPill">${d}</span>`)}
<span class="routePills">
${visible.map((r) => html`<span class="routePill">${r}</span>`)}
${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
</span>
`;
@@ -352,4 +365,16 @@ export class OpsViewCertificates extends DeesElement {
</span>
`;
}
private formatRetryTime(retryAfter?: string): string {
if (!retryAfter) return 'soon';
const retryDate = new Date(retryAfter);
const now = new Date();
const diffMs = retryDate.getTime() - now.getTime();
if (diffMs <= 0) return 'now';
const diffMin = Math.ceil(diffMs / 60000);
if (diffMin < 60) return `in ${diffMin}m`;
const diffHours = Math.ceil(diffMin / 60);
return `in ${diffHours}h`;
}
}