From c5e2c262b7167cab691ae50420f8a0ae3bef4ce2 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 13 Feb 2026 17:05:33 +0000 Subject: [PATCH] feat(certificates): add certificate overview and reprovisioning in ops UI and API; track SmartProxy certificate events --- changelog.md | 9 + package.json | 2 +- pnpm-lock.yaml | 10 +- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 48 ++- ts/opsserver/classes.opsserver.ts | 2 + ts/opsserver/handlers/certificate.handler.ts | 174 +++++++++ ts/opsserver/handlers/index.ts | 3 +- ts_interfaces/requests/certificate.ts | 54 +++ ts_interfaces/requests/index.ts | 3 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 100 +++++- ts_web/elements/index.ts | 1 + ts_web/elements/ops-dashboard.ts | 5 + ts_web/elements/ops-view-certificates.ts | 355 +++++++++++++++++++ ts_web/router.ts | 2 +- 16 files changed, 757 insertions(+), 15 deletions(-) create mode 100644 ts/opsserver/handlers/certificate.handler.ts create mode 100644 ts_interfaces/requests/certificate.ts create mode 100644 ts_web/elements/ops-view-certificates.ts diff --git a/changelog.md b/changelog.md index 8f3bc13..cc07c0b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-13 - 5.3.0 - feat(certificates) +add certificate overview and reprovisioning in ops UI and API; track SmartProxy certificate events + +- Add CertificateHandler with typedrequest endpoints: getCertificateOverview and reprovisionCertificate +- Introduce ICertificateInfo and request/response interfaces for certificate operations +- Frontend: add certificate state part, actions (fetchCertificateOverview, reprovisionCertificate), router view, and ops-view-certificates component +- DcRouter: add certificateStatusMap, listen to SmartProxy certificate-issued/renewed/failed events, and add findRouteNameForDomain helper +- Bump dependency @push.rocks/smartproxy to ^24.0.0 + ## 2026-02-13 - 5.2.0 - feat(monitoring) add throughput metrics and expose them in ops UI diff --git a/package.json b/package.json index 98ee690..9464505 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", - "@push.rocks/smartproxy": "^23.1.6", + "@push.rocks/smartproxy": "^24.0.0", "@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 a6aa853..be1738a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,8 +75,8 @@ importers: specifier: ^4.2.3 version: 4.2.3 '@push.rocks/smartproxy': - specifier: ^23.1.6 - version: 23.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7) + specifier: ^24.0.0 + version: 24.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) '@push.rocks/smartradius': specifier: ^1.1.1 version: 1.1.1 @@ -1040,8 +1040,8 @@ packages: '@push.rocks/smartpromise@4.2.3': resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartproxy@23.1.6': - resolution: {integrity: sha512-cwTK4d7vOP0nEZzkZSg9Ua7R+J7SIGId9G815GNTRYYZP20TZbvmWDZW/1gf2lw3AuAy2MRIJMPO2BZ7JnZckw==} + '@push.rocks/smartproxy@24.0.0': + resolution: {integrity: sha512-xSz6mrV59xmuiuaBgej6Fq611r9+Ay0ad2XiZAP/XGrkWykgQNeDZqzAq8dadmaCqO/3bVfH/mXlEYaKrDyTYA==} '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} @@ -6441,7 +6441,7 @@ snapshots: '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartproxy@23.1.6(@push.rocks/smartserve@2.0.1)(socks@2.8.7)': + '@push.rocks/smartproxy@24.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)': dependencies: '@push.rocks/lik': 6.2.2 '@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4c7415a..a2994f6 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.2.0', + version: '5.3.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 9c1ac99..216006e 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -183,6 +183,15 @@ export class DcRouter { public cacheDb?: CacheDb; public cacheCleaner?: CacheCleaner; + // Certificate status tracking from SmartProxy events + public certificateStatusMap = new Map(); + // TypedRouter for API endpoints public typedrouter = new plugins.typedrequest.TypedRouter(); @@ -479,14 +488,34 @@ export class DcRouter { if (acmeConfig) { this.smartProxy.on('certificate-issued', (event) => { console.log(`[DcRouter] Certificate issued for ${event.domain}, 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(), + }); + } }); - + this.smartProxy.on('certificate-renewed', (event) => { console.log(`[DcRouter] Certificate renewed for ${event.domain}, 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(), + }); + } }); - + this.smartProxy.on('certificate-failed', (event) => { console.error(`[DcRouter] Certificate failed for ${event.domain}:`, event.error); + const routeName = this.findRouteNameForDomain(event.domain); + if (routeName) { + this.certificateStatusMap.set(routeName, { + status: 'failed', domain: event.domain, error: event.error, + }); + } }); } @@ -658,6 +687,21 @@ export class DcRouter { return false; } + /** + * Find the route name that matches a given domain + */ + private findRouteNameForDomain(domain: string): string | undefined { + if (!this.smartProxy) return undefined; + 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]; + if (routeDomains.includes(domain)) return route.name; + } + return undefined; + } + public async stop() { console.log('Stopping DcRouter services...'); diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 0906bd0..e52adbf 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -18,6 +18,7 @@ export class OpsServer { private statsHandler: handlers.StatsHandler; private radiusHandler: handlers.RadiusHandler; private emailOpsHandler: handlers.EmailOpsHandler; + private certificateHandler: handlers.CertificateHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -57,6 +58,7 @@ export class OpsServer { this.statsHandler = new handlers.StatsHandler(this); this.radiusHandler = new handlers.RadiusHandler(this); this.emailOpsHandler = new handlers.EmailOpsHandler(this); + this.certificateHandler = new handlers.CertificateHandler(this); console.log('✅ OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts new file mode 100644 index 0000000..033fa0c --- /dev/null +++ b/ts/opsserver/handlers/certificate.handler.ts @@ -0,0 +1,174 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class CertificateHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + // Get Certificate Overview + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getCertificateOverview', + async (dataArg) => { + const certificates = this.buildCertificateOverview(); + const summary = this.buildSummary(certificates); + return { certificates, summary }; + } + ) + ); + + // Reprovision Certificate + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'reprovisionCertificate', + async (dataArg) => { + return this.reprovisionCertificate(dataArg.routeName); + } + ) + ); + } + + private buildCertificateOverview(): 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[] = []; + + for (const route of routes) { + if (!route.name) continue; + + const tls = route.action?.tls; + if (!tls) continue; + + // Skip passthrough routes - they don't manage certificates + if (tls.mode === 'passthrough') continue; + + const routeDomains = route.match.domains + ? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]) + : []; + + // 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 { + source = 'acme'; + } + } else if (tls.certificate && typeof tls.certificate === 'object') { + source = 'static'; + } + + // Start with unknown status + 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); + if (eventStatus) { + status = eventStatus.status; + expiryDate = eventStatus.expiryDate; + issuedAt = eventStatus.issuedAt; + error = eventStatus.error; + } + + // Try to get Rust-side certificate data + try { + // getCertificateStatus is async but we're in a sync context + // We'll rely on event-based data primarily + } catch { + // Ignore errors from Rust bridge + } + + // Compute status from expiry date if we have one and status is still valid/unknown + if (expiryDate && (status === 'valid' || status === 'unknown')) { + const expiry = new Date(expiryDate); + const now = new Date(); + const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + + if (daysUntilExpiry < 0) { + status = 'expired'; + } else if (daysUntilExpiry < 30) { + status = 'expiring'; + } else { + status = 'valid'; + } + } + + // Static certs with no other info default to 'valid' + if (source === 'static' && status === 'unknown') { + status = 'valid'; + } + + const canReprovision = source === 'acme' || source === 'provision-function'; + + certificates.push({ + routeName: route.name, + domains: routeDomains, + status, + source, + tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough', + expiryDate, + issuer, + issuedAt, + error, + canReprovision, + }); + } + + return certificates; + } + + private buildSummary(certificates: interfaces.requests.ICertificateInfo[]): { + total: number; + valid: number; + expiring: number; + expired: number; + failed: number; + unknown: number; + } { + const summary = { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 }; + summary.total = certificates.length; + for (const cert of certificates) { + switch (cert.status) { + case 'valid': summary.valid++; break; + case 'expiring': summary.expiring++; break; + case 'expired': summary.expired++; break; + case 'failed': summary.failed++; break; + case 'provisioning': // count as unknown + case 'unknown': summary.unknown++; break; + } + } + return summary; + } + + private async reprovisionCertificate(routeName: 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' }; + } + + try { + await smartProxy.provisionCertificate(routeName); + // Clear event-based status so it gets refreshed + dcRouter.certificateStatusMap.delete(routeName); + return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` }; + } catch (err) { + return { success: false, message: err.message || 'Failed to reprovision certificate' }; + } + } +} diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index 43ef784..c483f4b 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -4,4 +4,5 @@ export * from './logs.handler.js'; export * from './security.handler.js'; export * from './stats.handler.js'; export * from './radius.handler.js'; -export * from './email-ops.handler.js'; \ No newline at end of file +export * from './email-ops.handler.js'; +export * from './certificate.handler.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/certificate.ts b/ts_interfaces/requests/certificate.ts new file mode 100644 index 0000000..a0bd176 --- /dev/null +++ b/ts_interfaces/requests/certificate.ts @@ -0,0 +1,54 @@ +import * as plugins from '../plugins.js'; +import * as authInterfaces from '../data/auth.js'; + +export type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown'; +export type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none'; + +export interface ICertificateInfo { + routeName: string; + domains: string[]; + status: TCertificateStatus; + source: TCertificateSource; + tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough'; + expiryDate?: string; // ISO string + issuer?: string; + issuedAt?: string; // ISO string + error?: string; // if status === 'failed' + canReprovision: boolean; // true for acme/provision-function routes +} + +export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetCertificateOverview +> { + method: 'getCertificateOverview'; + request: { + identity?: authInterfaces.IIdentity; + }; + response: { + certificates: ICertificateInfo[]; + summary: { + total: number; + valid: number; + expiring: number; + expired: number; + failed: number; + unknown: number; + }; + }; +} + +export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ReprovisionCertificate +> { + method: 'reprovisionCertificate'; + request: { + identity?: authInterfaces.IIdentity; + routeName: string; + }; + response: { + success: boolean; + message?: string; + }; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index 567e306..1e3539e 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -4,4 +4,5 @@ export * from './logs.js'; export * from './stats.js'; export * from './combined.stats.js'; export * from './radius.js'; -export * from './email-ops.js'; \ No newline at end of file +export * from './email-ops.js'; +export * from './certificate.js'; \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 4c7415a..a2994f6 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.2.0', + version: '5.3.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 f9ec83c..42e130d 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -53,6 +53,14 @@ export interface INetworkState { error: string | null; } +export interface ICertificateState { + certificates: interfaces.requests.ICertificateInfo[]; + summary: { total: number; valid: number; expiring: number; expired: number; failed: number; unknown: number }; + isLoading: boolean; + error: string | null; + lastUpdated: number; +} + export interface IEmailOpsState { currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security'; queuedEmails: interfaces.requests.IEmailQueueItem[]; @@ -103,7 +111,7 @@ export const configStatePart = await appState.getStatePart( // Determine initial view from URL path const getInitialView = (): string => { const path = typeof window !== 'undefined' ? window.location.pathname : '/'; - const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security']; + const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates']; const segments = path.split('/').filter(Boolean); const view = segments[0]; return validViews.includes(view) ? view : 'overview'; @@ -162,6 +170,18 @@ export const emailOpsStatePart = await appState.getStatePart( 'soft' ); +export const certificateStatePart = await appState.getStatePart( + 'certificates', + { + certificates: [], + summary: { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 }, + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft' +); + // Actions for state management interface IActionContext { identity: interfaces.data.IIdentity | null; @@ -340,7 +360,14 @@ export const setActiveViewAction = uiStatePart.createAction(async (state networkStatePart.dispatchAction(fetchNetworkStatsAction, null); }, 100); } - + + // If switching to certificates view, ensure we fetch certificate data + if (viewName === 'certificates' && currentState.activeView !== 'certificates') { + setTimeout(() => { + certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); + }, 100); + } + return { ...currentState, activeView: viewName, @@ -641,6 +668,66 @@ export const removeFromSuppressionListAction = emailOpsStatePart.createAction { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetCertificateOverview + >('/typedrequest', 'getCertificateOverview'); + + const response = await request.fire({ + identity: context.identity, + }); + + return { + certificates: response.certificates, + summary: response.summary, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch certificate overview', + }; + } +}); + +export const reprovisionCertificateAction = certificateStatePart.createAction( + async (statePartArg, routeName) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ReprovisionCertificate + >('/typedrequest', 'reprovisionCertificate'); + + await request.fire({ + identity: context.identity, + routeName, + }); + + // Re-fetch overview after reprovisioning + await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to reprovision certificate', + }; + } + } +); + // Combined refresh action for efficient polling async function dispatchCombinedRefreshAction() { const context = getActionContext(); @@ -725,6 +812,15 @@ async function dispatchCombinedRefreshAction() { }); } } + + // Refresh certificate data if on certificates view + if (currentView === 'certificates') { + try { + await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); + } catch (error) { + console.error('Certificate refresh failed:', error); + } + } } catch (error) { console.error('Combined refresh failed:', error); } diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 46e90c8..15b93dc 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -5,4 +5,5 @@ export * from './ops-view-emails.js'; export * from './ops-view-logs.js'; export * from './ops-view-config.js'; export * from './ops-view-security.js'; +export * from './ops-view-certificates.js'; export * from './shared/index.js'; \ No newline at end of file diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index a1799d9..bcee590 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -19,6 +19,7 @@ import { OpsViewEmails } from './ops-view-emails.js'; import { OpsViewLogs } from './ops-view-logs.js'; import { OpsViewConfig } from './ops-view-config.js'; import { OpsViewSecurity } from './ops-view-security.js'; +import { OpsViewCertificates } from './ops-view-certificates.js'; @customElement('ops-dashboard') export class OpsDashboard extends DeesElement { @@ -61,6 +62,10 @@ export class OpsDashboard extends DeesElement { name: 'Security', element: OpsViewSecurity, }, + { + name: 'Certificates', + element: OpsViewCertificates, + }, ]; /** diff --git a/ts_web/elements/ops-view-certificates.ts b/ts_web/elements/ops-view-certificates.ts new file mode 100644 index 0000000..49ef175 --- /dev/null +++ b/ts_web/elements/ops-view-certificates.ts @@ -0,0 +1,355 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as appstate from '../appstate.js'; +import * as interfaces from '../../dist_ts_interfaces/index.js'; +import { viewHostCss } from './shared/css.js'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-certificates': OpsViewCertificates; + } +} + +@customElement('ops-view-certificates') +export class OpsViewCertificates extends DeesElement { + @state() + accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState(); + + constructor() { + super(); + const sub = appstate.certificateStatePart.state.subscribe((newState) => { + this.certState = newState; + }); + this.rxSubscriptions.push(sub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .certificatesContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .statusBadge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .statusBadge.valid { + background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; + color: ${cssManager.bdTheme('#166534', '#4ade80')}; + } + + .statusBadge.expiring { + background: ${cssManager.bdTheme('#fff7ed', '#431407')}; + color: ${cssManager.bdTheme('#9a3412', '#fb923c')}; + } + + .statusBadge.expired, + .statusBadge.failed { + background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + } + + .statusBadge.provisioning { + background: ${cssManager.bdTheme('#eff6ff', '#172554')}; + color: ${cssManager.bdTheme('#1e40af', '#60a5fa')}; + } + + .statusBadge.unknown { + background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; + color: ${cssManager.bdTheme('#4b5563', '#9ca3af')}; + } + + .sourceBadge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; + color: ${cssManager.bdTheme('#374151', '#d1d5db')}; + } + + .domainPills { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .domainPill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')}; + color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')}; + } + + .moreCount { + font-size: 11px; + color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; + padding: 2px 6px; + } + + .errorText { + font-size: 12px; + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .expiryInfo { + font-size: 12px; + } + + .expiryInfo .daysLeft { + font-size: 11px; + color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; + } + + .expiryInfo .daysLeft.warn { + color: ${cssManager.bdTheme('#9a3412', '#fb923c')}; + } + + .expiryInfo .daysLeft.danger { + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + } + `, + ]; + + public render(): TemplateResult { + const { summary } = this.certState; + + return html` + Certificates + +
+ ${this.renderStatsTiles(summary)} + ${this.renderCertificateTable()} +
+ `; + } + + private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult { + const tiles: IStatsTile[] = [ + { + id: 'total', + title: 'Total Certificates', + value: summary.total, + type: 'number', + icon: 'shieldHalved', + color: '#3b82f6', + }, + { + id: 'valid', + title: 'Valid', + value: summary.valid, + type: 'number', + icon: 'check', + color: '#22c55e', + }, + { + id: 'expiring', + title: 'Expiring Soon', + value: summary.expiring, + type: 'number', + icon: 'clock', + color: '#f59e0b', + }, + { + id: 'problems', + title: 'Failed / Expired', + value: summary.failed + summary.expired, + type: 'number', + icon: 'triangleExclamation', + color: '#ef4444', + }, + ]; + + return html` + { + await appstate.certificateStatePart.dispatchAction( + appstate.fetchCertificateOverviewAction, + null + ); + }, + }, + ]} + > + `; + } + + private renderCertificateTable(): TemplateResult { + return html` + ({ + Route: cert.routeName, + Domains: this.renderDomainPills(cert.domains), + Status: this.renderStatusBadge(cert.status), + Source: this.renderSourceBadge(cert.source), + Expires: this.renderExpiry(cert.expiryDate), + Error: cert.error + ? html`${cert.error}` + : '', + })} + .dataActions=${[ + { + name: 'Reprovision', + iconName: 'arrowsRotate', + type: ['inRow'], + actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { + const cert = actionData.item; + if (!cert.canReprovision) { + const { DeesToast } = await import('@design.estate/dees-catalog'); + DeesToast.show({ + message: 'This certificate source does not support reprovisioning.', + type: 'warning', + duration: 3000, + }); + return; + } + await appstate.certificateStatePart.dispatchAction( + appstate.reprovisionCertificateAction, + cert.routeName, + ); + const { DeesToast } = await import('@design.estate/dees-catalog'); + DeesToast.show({ + message: `Reprovisioning triggered for ${cert.routeName}`, + type: 'success', + duration: 3000, + }); + }, + }, + { + name: 'View Details', + iconName: 'magnifyingGlass', + type: ['doubleClick', 'contextmenu'], + actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { + const cert = actionData.item; + const { DeesModal } = await import('@design.estate/dees-catalog'); + await DeesModal.createAndShow({ + heading: `Certificate: ${cert.routeName}`, + content: html` +
+ +
+ `, + menuOptions: [ + { + name: 'Copy Route Name', + iconName: 'copy', + action: async () => { + await navigator.clipboard.writeText(cert.routeName); + }, + }, + ], + }); + }, + }, + ]} + heading1="Certificate Status" + heading2="TLS certificates across all routes" + searchable + .pagination=${true} + .paginationSize=${50} + dataName="certificate" + >
+ `; + } + + private renderDomainPills(domains: string[]): TemplateResult { + const maxShow = 3; + const visible = domains.slice(0, maxShow); + const remaining = domains.length - maxShow; + + return html` + + ${visible.map((d) => html`${d}`)} + ${remaining > 0 ? html`+${remaining} more` : ''} + + `; + } + + private renderStatusBadge(status: interfaces.requests.TCertificateStatus): TemplateResult { + return html`${status}`; + } + + private renderSourceBadge(source: interfaces.requests.TCertificateSource): TemplateResult { + const labels: Record = { + acme: 'ACME', + 'provision-function': 'Custom', + static: 'Static', + none: 'None', + }; + return html`${labels[source] || source}`; + } + + private renderExpiry(expiryDate?: string): TemplateResult { + if (!expiryDate) { + return html`--`; + } + + const expiry = new Date(expiryDate); + const now = new Date(); + const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + + const dateStr = expiry.toLocaleDateString(); + let daysClass = ''; + let daysText = ''; + + if (daysLeft < 0) { + daysClass = 'danger'; + daysText = `(expired)`; + } else if (daysLeft < 30) { + daysClass = 'warn'; + daysText = `(${daysLeft}d left)`; + } else { + daysText = `(${daysLeft}d left)`; + } + + return html` + + ${dateStr} ${daysText} + + `; + } +} diff --git a/ts_web/router.ts b/ts_web/router.ts index 9c7a729..ce595e1 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -3,7 +3,7 @@ import * as appstate from './appstate.js'; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; -export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security'] as const; +export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'] as const; export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const; export type TValidView = typeof validViews[number];