@serve.zone/dcrouter 5.5.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ import { logger } from './logger.js';
14
14
  // Import storage manager
15
15
  import { StorageManager, type IStorageConfig } from './storage/index.js';
16
16
  import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
17
+ import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
17
18
  // Import cache system
18
19
  import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
19
20
 
@@ -184,16 +185,19 @@ export class DcRouter {
184
185
  public cacheDb?: CacheDb;
185
186
  public cacheCleaner?: CacheCleaner;
186
187
 
187
- // Certificate status tracking from SmartProxy events
188
+ // Certificate status tracking from SmartProxy events (keyed by domain)
188
189
  public certificateStatusMap = new Map<string, {
189
190
  status: 'valid' | 'failed';
190
- domain: string;
191
+ routeNames: string[];
191
192
  expiryDate?: string;
192
193
  issuedAt?: string;
193
194
  source?: string;
194
195
  error?: string;
195
196
  }>();
196
197
 
198
+ // Certificate provisioning scheduler with per-domain backoff
199
+ public certProvisionScheduler?: CertProvisionScheduler;
200
+
197
201
  // TypedRouter for API endpoints
198
202
  public typedrouter = new plugins.typedrequest.TypedRouter();
199
203
 
@@ -467,6 +471,9 @@ export class DcRouter {
467
471
  },
468
472
  };
469
473
 
474
+ // Initialize cert provision scheduler
475
+ this.certProvisionScheduler = new CertProvisionScheduler(this.storageManager);
476
+
470
477
  // If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
471
478
  if (challengeHandlers.length > 0) {
472
479
  this.smartAcme = new plugins.smartacme.SmartAcme({
@@ -478,15 +485,25 @@ export class DcRouter {
478
485
  });
479
486
  await this.smartAcme.start();
480
487
 
488
+ const scheduler = this.certProvisionScheduler;
481
489
  smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
490
+ // Check backoff before attempting provision
491
+ if (await scheduler.isInBackoff(domain)) {
492
+ const info = await scheduler.getBackoffInfo(domain);
493
+ const msg = `Domain ${domain} is in backoff (${info?.failures} failures), retry after ${info?.retryAfter}`;
494
+ eventComms.warn(msg);
495
+ throw new Error(msg);
496
+ }
497
+
482
498
  try {
499
+ // smartacme v9 handles concurrency, per-domain dedup, and rate limiting internally
483
500
  eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
484
501
  eventComms.setSource('smartacme-dns-01');
485
502
  const cert = await this.smartAcme.getCertificateForDomain(domain);
486
503
  if (cert.validUntil) {
487
504
  eventComms.setExpiryDate(new Date(cert.validUntil));
488
505
  }
489
- return {
506
+ const result = {
490
507
  id: cert.id,
491
508
  domainName: cert.domainName,
492
509
  created: cert.created,
@@ -495,7 +512,13 @@ export class DcRouter {
495
512
  publicKey: cert.publicKey,
496
513
  csr: cert.csr,
497
514
  };
515
+
516
+ // Success — clear any backoff
517
+ await scheduler.clearBackoff(domain);
518
+ return result;
498
519
  } catch (err) {
520
+ // Record failure for backoff tracking
521
+ await scheduler.recordFailure(domain, err.message);
499
522
  eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${err.message}, falling back to http-01`);
500
523
  return 'http01';
501
524
  }
@@ -519,39 +542,34 @@ export class DcRouter {
519
542
  });
520
543
 
521
544
  // Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
545
+ // Events are keyed by domain for domain-centric certificate tracking
522
546
  this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
523
547
  console.log(`[DcRouter] Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
524
- const routeName = this.findRouteNameForDomain(event.domain);
525
- if (routeName) {
526
- this.certificateStatusMap.set(routeName, {
527
- status: 'valid', domain: event.domain,
528
- expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
529
- source: event.source,
530
- });
531
- }
548
+ const routeNames = this.findRouteNamesForDomain(event.domain);
549
+ this.certificateStatusMap.set(event.domain, {
550
+ status: 'valid', routeNames,
551
+ expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
552
+ source: event.source,
553
+ });
532
554
  });
533
555
 
534
556
  this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
535
557
  console.log(`[DcRouter] Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
536
- const routeName = this.findRouteNameForDomain(event.domain);
537
- if (routeName) {
538
- this.certificateStatusMap.set(routeName, {
539
- status: 'valid', domain: event.domain,
540
- expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
541
- source: event.source,
542
- });
543
- }
558
+ const routeNames = this.findRouteNamesForDomain(event.domain);
559
+ this.certificateStatusMap.set(event.domain, {
560
+ status: 'valid', routeNames,
561
+ expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
562
+ source: event.source,
563
+ });
544
564
  });
545
565
 
546
566
  this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
547
567
  console.error(`[DcRouter] Certificate failed for ${event.domain} (${event.source}):`, event.error);
548
- const routeName = this.findRouteNameForDomain(event.domain);
549
- if (routeName) {
550
- this.certificateStatusMap.set(routeName, {
551
- status: 'failed', domain: event.domain, error: event.error,
552
- source: event.source,
553
- });
554
- }
568
+ const routeNames = this.findRouteNamesForDomain(event.domain);
569
+ this.certificateStatusMap.set(event.domain, {
570
+ status: 'failed', routeNames, error: event.error,
571
+ source: event.source,
572
+ });
555
573
  });
556
574
 
557
575
  // Start SmartProxy
@@ -724,7 +742,7 @@ export class DcRouter {
724
742
  }
725
743
 
726
744
  /**
727
- * Find the route name that matches a given domain
745
+ * Find the first route name that matches a given domain
728
746
  */
729
747
  private findRouteNameForDomain(domain: string): string | undefined {
730
748
  if (!this.smartProxy) return undefined;
@@ -740,6 +758,27 @@ export class DcRouter {
740
758
  return undefined;
741
759
  }
742
760
 
761
+ /**
762
+ * Find ALL route names that match a given domain
763
+ */
764
+ public findRouteNamesForDomain(domain: string): string[] {
765
+ if (!this.smartProxy) return [];
766
+ const names: string[] = [];
767
+ for (const route of this.smartProxy.routeManager.getRoutes()) {
768
+ if (!route.match.domains || !route.name) continue;
769
+ const routeDomains = Array.isArray(route.match.domains)
770
+ ? route.match.domains
771
+ : [route.match.domains];
772
+ for (const pattern of routeDomains) {
773
+ if (this.isDomainMatch(domain, pattern)) {
774
+ names.push(route.name);
775
+ break; // This route already matched, no need to check other patterns
776
+ }
777
+ }
778
+ }
779
+ return names;
780
+ }
781
+
743
782
  public async stop() {
744
783
  console.log('Stopping DcRouter services...');
745
784
 
@@ -23,24 +23,45 @@ export class CertificateHandler {
23
23
  )
24
24
  );
25
25
 
26
- // Reprovision Certificate
26
+ // Legacy route-based reprovision (backward compat)
27
27
  this.typedrouter.addTypedHandler(
28
28
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
29
29
  'reprovisionCertificate',
30
30
  async (dataArg) => {
31
- return this.reprovisionCertificate(dataArg.routeName);
31
+ return this.reprovisionCertificateByRoute(dataArg.routeName);
32
+ }
33
+ )
34
+ );
35
+
36
+ // Domain-based reprovision (preferred)
37
+ this.typedrouter.addTypedHandler(
38
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
39
+ 'reprovisionCertificateDomain',
40
+ async (dataArg) => {
41
+ return this.reprovisionCertificateDomain(dataArg.domain);
32
42
  }
33
43
  )
34
44
  );
35
45
  }
36
46
 
47
+ /**
48
+ * Build domain-centric certificate overview.
49
+ * Instead of one row per route, we produce one row per unique domain.
50
+ */
37
51
  private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
38
52
  const dcRouter = this.opsServerRef.dcRouterRef;
39
53
  const smartProxy = dcRouter.smartProxy;
40
54
  if (!smartProxy) return [];
41
55
 
42
56
  const routes = smartProxy.routeManager.getRoutes();
43
- const certificates: interfaces.requests.ICertificateInfo[] = [];
57
+
58
+ // Phase 1: Collect unique domains with their associated route info
59
+ const domainMap = new Map<string, {
60
+ routeNames: string[];
61
+ source: interfaces.requests.TCertificateSource;
62
+ tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
63
+ canReprovision: boolean;
64
+ }>();
44
65
 
45
66
  for (const route of routes) {
46
67
  if (!route.name) continue;
@@ -58,7 +79,6 @@ export class CertificateHandler {
58
79
  // Determine source
59
80
  let source: interfaces.requests.TCertificateSource = 'none';
60
81
  if (tls.certificate === 'auto') {
61
- // Check if a certProvisionFunction is configured
62
82
  if ((smartProxy.settings as any).certProvisionFunction) {
63
83
  source = 'provision-function';
64
84
  } else {
@@ -68,15 +88,44 @@ export class CertificateHandler {
68
88
  source = 'static';
69
89
  }
70
90
 
71
- // Start with unknown status
91
+ const canReprovision = source === 'acme' || source === 'provision-function';
92
+ const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
93
+
94
+ for (const domain of routeDomains) {
95
+ const existing = domainMap.get(domain);
96
+ if (existing) {
97
+ // Add this route name to the existing domain entry
98
+ if (!existing.routeNames.includes(route.name)) {
99
+ existing.routeNames.push(route.name);
100
+ }
101
+ // Upgrade source if more specific
102
+ if (existing.source === 'none' && source !== 'none') {
103
+ existing.source = source;
104
+ existing.canReprovision = canReprovision;
105
+ }
106
+ } else {
107
+ domainMap.set(domain, {
108
+ routeNames: [route.name],
109
+ source,
110
+ tlsMode,
111
+ canReprovision,
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ // Phase 2: Resolve status for each unique domain
118
+ const certificates: interfaces.requests.ICertificateInfo[] = [];
119
+
120
+ for (const [domain, info] of domainMap) {
72
121
  let status: interfaces.requests.TCertificateStatus = 'unknown';
73
122
  let expiryDate: string | undefined;
74
123
  let issuedAt: string | undefined;
75
124
  let issuer: string | undefined;
76
125
  let error: string | undefined;
77
126
 
78
- // Check event-based status from DcRouter's certificateStatusMap
79
- const eventStatus = dcRouter.certificateStatusMap.get(route.name);
127
+ // Check event-based status from certificateStatusMap (now keyed by domain)
128
+ const eventStatus = dcRouter.certificateStatusMap.get(domain);
80
129
  if (eventStatus) {
81
130
  status = eventStatus.status;
82
131
  expiryDate = eventStatus.expiryDate;
@@ -87,10 +136,10 @@ export class CertificateHandler {
87
136
  }
88
137
  }
89
138
 
90
- // Try Rust-side certificate status if no event data
91
- if (status === 'unknown') {
139
+ // Try SmartProxy certificate status if no event data
140
+ if (status === 'unknown' && info.routeNames.length > 0) {
92
141
  try {
93
- const rustStatus = await smartProxy.getCertificateStatus(route.name);
142
+ const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
94
143
  if (rustStatus) {
95
144
  if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
96
145
  if (rustStatus.issuer) issuer = rustStatus.issuer;
@@ -105,22 +154,19 @@ export class CertificateHandler {
105
154
  }
106
155
 
107
156
  // Check persisted cert data from StorageManager
108
- if (status === 'unknown' && routeDomains.length > 0) {
109
- for (const domain of routeDomains) {
110
- if (expiryDate) break;
111
- const cleanDomain = domain.replace(/^\*\.?/, '');
112
- const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
113
- if (certData?.validUntil) {
114
- expiryDate = new Date(certData.validUntil).toISOString();
115
- if (certData.created) {
116
- issuedAt = new Date(certData.created).toISOString();
117
- }
118
- issuer = 'smartacme-dns-01';
157
+ if (status === 'unknown') {
158
+ const cleanDomain = domain.replace(/^\*\.?/, '');
159
+ const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
160
+ if (certData?.validUntil) {
161
+ expiryDate = new Date(certData.validUntil).toISOString();
162
+ if (certData.created) {
163
+ issuedAt = new Date(certData.created).toISOString();
119
164
  }
165
+ issuer = 'smartacme-dns-01';
120
166
  }
121
167
  }
122
168
 
123
- // Compute status from expiry date if we have one and status is still valid/unknown
169
+ // Compute status from expiry date
124
170
  if (expiryDate && (status === 'valid' || status === 'unknown')) {
125
171
  const expiry = new Date(expiryDate);
126
172
  const now = new Date();
@@ -136,28 +182,36 @@ export class CertificateHandler {
136
182
  }
137
183
 
138
184
  // Static certs with no other info default to 'valid'
139
- if (source === 'static' && status === 'unknown') {
185
+ if (info.source === 'static' && status === 'unknown') {
140
186
  status = 'valid';
141
187
  }
142
188
 
143
189
  // ACME/provision-function routes with no cert data are still provisioning
144
- if (status === 'unknown' && (source === 'acme' || source === 'provision-function')) {
190
+ if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
145
191
  status = 'provisioning';
146
192
  }
147
193
 
148
- const canReprovision = source === 'acme' || source === 'provision-function';
194
+ // Phase 3: Attach backoff info
195
+ let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
196
+ if (dcRouter.certProvisionScheduler) {
197
+ const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
198
+ if (bi) {
199
+ backoffInfo = bi;
200
+ }
201
+ }
149
202
 
150
203
  certificates.push({
151
- routeName: route.name,
152
- domains: routeDomains,
204
+ domain,
205
+ routeNames: info.routeNames,
153
206
  status,
154
- source,
155
- tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough',
207
+ source: info.source,
208
+ tlsMode: info.tlsMode,
156
209
  expiryDate,
157
210
  issuer,
158
211
  issuedAt,
159
212
  error,
160
- canReprovision,
213
+ canReprovision: info.canReprovision,
214
+ backoffInfo,
161
215
  });
162
216
  }
163
217
 
@@ -187,7 +241,10 @@ export class CertificateHandler {
187
241
  return summary;
188
242
  }
189
243
 
190
- private async reprovisionCertificate(routeName: string): Promise<{ success: boolean; message?: string }> {
244
+ /**
245
+ * Legacy route-based reprovisioning
246
+ */
247
+ private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
191
248
  const dcRouter = this.opsServerRef.dcRouterRef;
192
249
  const smartProxy = dcRouter.smartProxy;
193
250
 
@@ -197,11 +254,58 @@ export class CertificateHandler {
197
254
 
198
255
  try {
199
256
  await smartProxy.provisionCertificate(routeName);
200
- // Clear event-based status so it gets refreshed
201
- dcRouter.certificateStatusMap.delete(routeName);
257
+ // Clear event-based status for domains in this route
258
+ for (const [domain, entry] of dcRouter.certificateStatusMap) {
259
+ if (entry.routeNames.includes(routeName)) {
260
+ dcRouter.certificateStatusMap.delete(domain);
261
+ }
262
+ }
202
263
  return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
203
264
  } catch (err) {
204
265
  return { success: false, message: err.message || 'Failed to reprovision certificate' };
205
266
  }
206
267
  }
268
+
269
+ /**
270
+ * Domain-based reprovisioning — clears backoff first, then triggers provision
271
+ */
272
+ private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
273
+ const dcRouter = this.opsServerRef.dcRouterRef;
274
+ const smartProxy = dcRouter.smartProxy;
275
+
276
+ if (!smartProxy) {
277
+ return { success: false, message: 'SmartProxy is not running' };
278
+ }
279
+
280
+ // Clear backoff for this domain (user override)
281
+ if (dcRouter.certProvisionScheduler) {
282
+ await dcRouter.certProvisionScheduler.clearBackoff(domain);
283
+ }
284
+
285
+ // Clear status map entry so it gets refreshed
286
+ dcRouter.certificateStatusMap.delete(domain);
287
+
288
+ // Try to provision via SmartAcme directly
289
+ if (dcRouter.smartAcme) {
290
+ try {
291
+ await dcRouter.smartAcme.getCertificateForDomain(domain);
292
+ return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
293
+ } catch (err) {
294
+ return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
295
+ }
296
+ }
297
+
298
+ // Fallback: try provisioning via the first matching route
299
+ const routeNames = dcRouter.findRouteNamesForDomain(domain);
300
+ if (routeNames.length > 0) {
301
+ try {
302
+ await smartProxy.provisionCertificate(routeNames[0]);
303
+ return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
304
+ } catch (err) {
305
+ return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
306
+ }
307
+ }
308
+
309
+ return { success: false, message: `No routes found for domain '${domain}'` };
310
+ }
207
311
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '5.5.0',
6
+ version: '6.2.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -719,18 +719,18 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
719
719
  });
720
720
 
721
721
  export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
722
- async (statePartArg, routeName) => {
722
+ async (statePartArg, domain) => {
723
723
  const context = getActionContext();
724
724
  const currentState = statePartArg.getState();
725
725
 
726
726
  try {
727
727
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
728
- interfaces.requests.IReq_ReprovisionCertificate
729
- >('/typedrequest', 'reprovisionCertificate');
728
+ interfaces.requests.IReq_ReprovisionCertificateDomain
729
+ >('/typedrequest', 'reprovisionCertificateDomain');
730
730
 
731
731
  await request.fire({
732
732
  identity: context.identity,
733
- routeName,
733
+ domain,
734
734
  });
735
735
 
736
736
  // Re-fetch overview after reprovisioning
@@ -94,13 +94,13 @@ export class OpsViewCertificates extends DeesElement {
94
94
  color: ${cssManager.bdTheme('#374151', '#d1d5db')};
95
95
  }
96
96
 
97
- .domainPills {
97
+ .routePills {
98
98
  display: flex;
99
99
  flex-wrap: wrap;
100
100
  gap: 4px;
101
101
  }
102
102
 
103
- .domainPill {
103
+ .routePill {
104
104
  display: inline-flex;
105
105
  align-items: center;
106
106
  padding: 2px 8px;
@@ -125,6 +125,17 @@ export class OpsViewCertificates extends DeesElement {
125
125
  white-space: nowrap;
126
126
  }
127
127
 
128
+ .backoffIndicator {
129
+ display: inline-flex;
130
+ align-items: center;
131
+ gap: 4px;
132
+ font-size: 11px;
133
+ color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
134
+ padding: 2px 6px;
135
+ border-radius: 4px;
136
+ background: ${cssManager.bdTheme('#fff7ed', '#431407')};
137
+ }
138
+
128
139
  .expiryInfo {
129
140
  font-size: 12px;
130
141
  }
@@ -218,14 +229,16 @@ export class OpsViewCertificates extends DeesElement {
218
229
  <dees-table
219
230
  .data=${this.certState.certificates}
220
231
  .displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
221
- Route: cert.routeName,
222
- Domains: this.renderDomainPills(cert.domains),
232
+ Domain: cert.domain,
233
+ Routes: this.renderRoutePills(cert.routeNames),
223
234
  Status: this.renderStatusBadge(cert.status),
224
235
  Source: this.renderSourceBadge(cert.source),
225
236
  Expires: this.renderExpiry(cert.expiryDate),
226
- Error: cert.error
227
- ? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
228
- : '',
237
+ Error: cert.backoffInfo
238
+ ? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
239
+ : cert.error
240
+ ? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
241
+ : '',
229
242
  })}
230
243
  .dataActions=${[
231
244
  {
@@ -245,11 +258,11 @@ export class OpsViewCertificates extends DeesElement {
245
258
  }
246
259
  await appstate.certificateStatePart.dispatchAction(
247
260
  appstate.reprovisionCertificateAction,
248
- cert.routeName,
261
+ cert.domain,
249
262
  );
250
263
  const { DeesToast } = await import('@design.estate/dees-catalog');
251
264
  DeesToast.show({
252
- message: `Reprovisioning triggered for ${cert.routeName}`,
265
+ message: `Reprovisioning triggered for ${cert.domain}`,
253
266
  type: 'success',
254
267
  duration: 3000,
255
268
  });
@@ -263,7 +276,7 @@ export class OpsViewCertificates extends DeesElement {
263
276
  const cert = actionData.item;
264
277
  const { DeesModal } = await import('@design.estate/dees-catalog');
265
278
  await DeesModal.createAndShow({
266
- heading: `Certificate: ${cert.routeName}`,
279
+ heading: `Certificate: ${cert.domain}`,
267
280
  content: html`
268
281
  <div style="padding: 20px;">
269
282
  <dees-dataview-codebox
@@ -275,10 +288,10 @@ export class OpsViewCertificates extends DeesElement {
275
288
  `,
276
289
  menuOptions: [
277
290
  {
278
- name: 'Copy Route Name',
291
+ name: 'Copy Domain',
279
292
  iconName: 'copy',
280
293
  action: async () => {
281
- await navigator.clipboard.writeText(cert.routeName);
294
+ await navigator.clipboard.writeText(cert.domain);
282
295
  },
283
296
  },
284
297
  ],
@@ -287,7 +300,7 @@ export class OpsViewCertificates extends DeesElement {
287
300
  },
288
301
  ]}
289
302
  heading1="Certificate Status"
290
- heading2="TLS certificates across all routes"
303
+ heading2="TLS certificates by domain"
291
304
  searchable
292
305
  .pagination=${true}
293
306
  .paginationSize=${50}
@@ -296,14 +309,14 @@ export class OpsViewCertificates extends DeesElement {
296
309
  `;
297
310
  }
298
311
 
299
- private renderDomainPills(domains: string[]): TemplateResult {
312
+ private renderRoutePills(routeNames: string[]): TemplateResult {
300
313
  const maxShow = 3;
301
- const visible = domains.slice(0, maxShow);
302
- const remaining = domains.length - maxShow;
314
+ const visible = routeNames.slice(0, maxShow);
315
+ const remaining = routeNames.length - maxShow;
303
316
 
304
317
  return html`
305
- <span class="domainPills">
306
- ${visible.map((d) => html`<span class="domainPill">${d}</span>`)}
318
+ <span class="routePills">
319
+ ${visible.map((r) => html`<span class="routePill">${r}</span>`)}
307
320
  ${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
308
321
  </span>
309
322
  `;
@@ -352,4 +365,16 @@ export class OpsViewCertificates extends DeesElement {
352
365
  </span>
353
366
  `;
354
367
  }
368
+
369
+ private formatRetryTime(retryAfter?: string): string {
370
+ if (!retryAfter) return 'soon';
371
+ const retryDate = new Date(retryAfter);
372
+ const now = new Date();
373
+ const diffMs = retryDate.getTime() - now.getTime();
374
+ if (diffMs <= 0) return 'now';
375
+ const diffMin = Math.ceil(diffMs / 60000);
376
+ if (diffMin < 60) return `in ${diffMin}m`;
377
+ const diffHours = Math.ceil(diffMin / 60);
378
+ return `in ${diffHours}h`;
379
+ }
355
380
  }
package/ts_web/readme.md CHANGED
@@ -34,6 +34,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
34
34
  - **Security** — Security incidents from email processing
35
35
  - Bounce record management and suppression list controls
36
36
 
37
+ ### 🔐 Certificate Management
38
+ - Domain-centric certificate overview with status indicators
39
+ - Certificate source tracking (ACME, provision function, static)
40
+ - Expiry date monitoring and alerts
41
+ - Per-domain backoff status for failed provisions
42
+ - One-click reprovisioning per domain
43
+
37
44
  ### 📜 Log Viewer
38
45
  - Real-time log streaming
39
46
  - Filter by log level (error, warning, info, debug)
@@ -77,6 +84,7 @@ ts_web/
77
84
  ├── ops-view-overview.ts # Overview statistics
78
85
  ├── ops-view-network.ts # Network monitoring
79
86
  ├── ops-view-emails.ts # Email queue management
87
+ ├── ops-view-certificates.ts # Certificate overview & reprovisioning
80
88
  ├── ops-view-logs.ts # Log viewer
81
89
  ├── ops-view-config.ts # Configuration display
82
90
  ├── ops-view-security.ts # Security dashboard
@@ -132,6 +140,7 @@ removeFromSuppressionAction(email) // Remove from suppression list
132
140
  /emails/sent → Sent emails
133
141
  /emails/failed → Failed emails
134
142
  /emails/security → Security incidents
143
+ /certificates → Certificate management
135
144
  /logs → Log viewer
136
145
  /configuration → System configuration
137
146
  /security → Security dashboard