diff --git a/changelog.md b/changelog.md index bebbde1..30bc729 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/package.json b/package.json index 0bcc669..28fc111 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b34c63..d204424 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 9c5a0c2..6a8e1c2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/classes.cert-provision-scheduler.ts b/ts/classes.cert-provision-scheduler.ts new file mode 100644 index 0000000..035c3c7 --- /dev/null +++ b/ts/classes.cert-provision-scheduler.ts @@ -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; + resolve: (value: any) => void; + reject: (err: any) => void; + }> = []; + private processing = false; + + // In-memory backoff cache (mirrors storage for fast lookups) + private backoffCache = new Map(); + + 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 { + const cached = this.backoffCache.get(domain); + if (cached) return cached; + + const entry = await this.storageManager.getJSON(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 { + 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 { + 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 { + 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 { + 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(domain: string, fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ domain, fn, resolve, reject }); + this.processQueue(); + }); + } + + /** + * Process the stagger queue serially + */ + private async processQueue(): Promise { + 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((r) => setTimeout(r, this.staggerDelayMs)); + } + } + + this.processing = false; + } +} diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index e03bfc3..4f2d3ce 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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(); + // 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...'); diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts index d733d94..6ab27e0 100644 --- a/ts/opsserver/handlers/certificate.handler.ts +++ b/ts/opsserver/handlers/certificate.handler.ts @@ -23,24 +23,45 @@ export class CertificateHandler { ) ); - // Reprovision Certificate + // Legacy route-based reprovision (backward compat) this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'reprovisionCertificate', async (dataArg) => { - return this.reprovisionCertificate(dataArg.routeName); + return this.reprovisionCertificateByRoute(dataArg.routeName); + } + ) + ); + + // Domain-based reprovision (preferred) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + '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 { 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(); 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}'` }; + } } diff --git a/ts_interfaces/requests/certificate.ts b/ts_interfaces/requests/certificate.ts index a0bd176..6930cc0 100644 --- a/ts_interfaces/requests/certificate.ts +++ b/ts_interfaces/requests/certificate.ts @@ -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; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 9c5a0c2..6a8e1c2 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 2703cac..6f5b1f7 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -719,18 +719,18 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction( }); export const reprovisionCertificateAction = certificateStatePart.createAction( - 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 diff --git a/ts_web/elements/ops-view-certificates.ts b/ts_web/elements/ops-view-certificates.ts index 49ef175..70fdcbf 100644 --- a/ts_web/elements/ops-view-certificates.ts +++ b/ts_web/elements/ops-view-certificates.ts @@ -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 { ({ - 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`${cert.error}` - : '', + Error: cert.backoffInfo + ? html`${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}` + : cert.error + ? html`${cert.error}` + : '', })} .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`
{ - 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` - - ${visible.map((d) => html`${d}`)} + + ${visible.map((r) => html`${r}`)} ${remaining > 0 ? html`+${remaining} more` : ''} `; @@ -352,4 +365,16 @@ export class OpsViewCertificates extends DeesElement { `; } + + 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`; + } }