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')}; } .routePills { display: flex; flex-wrap: wrap; gap: 4px; } .routePill { 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; } .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; } .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` ({ 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.backoffInfo ? html`${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}` : 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.domain, ); const { DeesToast } = await import('@design.estate/dees-catalog'); DeesToast.show({ message: `Reprovisioning triggered for ${cert.domain}`, 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.domain}`, content: html`
`, menuOptions: [ { name: 'Copy Domain', iconName: 'copy', action: async () => { await navigator.clipboard.writeText(cert.domain); }, }, ], }); }, }, ]} heading1="Certificate Status" heading2="TLS certificates by domain" searchable .pagination=${true} .paginationSize=${50} dataName="certificate" >
`; } private renderRoutePills(routeNames: string[]): TemplateResult { const maxShow = 3; const visible = routeNames.slice(0, maxShow); const remaining = routeNames.length - maxShow; return html` ${visible.map((r) => html`${r}`)} ${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} `; } 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`; } }