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: 'lucide:ShieldHalf', color: '#3b82f6', }, { id: 'valid', title: 'Valid', value: summary.valid, type: 'number', icon: 'lucide:Check', color: '#22c55e', }, { id: 'expiring', title: 'Expiring Soon', value: summary.expiring, type: 'number', icon: 'lucide:Clock', color: '#f59e0b', }, { id: 'problems', title: 'Failed / Expired', value: summary.failed + summary.expired, type: 'number', icon: 'lucide:TriangleAlert', 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: 'Import Certificate', iconName: 'lucide:upload', type: ['header'], actionFunc: async () => { const { DeesModal } = await import('@design.estate/dees-catalog'); await DeesModal.createAndShow({ heading: 'Import Certificate', content: html` `, menuOptions: [ { name: 'Import', iconName: 'lucide:upload', action: async (modal) => { const { DeesToast } = await import('@design.estate/dees-catalog'); try { const form = modal.shadowRoot.querySelector('dees-form') as any; const formData = await form.collectFormData(); const files = formData.certJsonFile; if (!files || files.length === 0) { DeesToast.show({ message: 'Please select a JSON file.', type: 'warning', duration: 3000 }); return; } const file = files[0]; const text = await file.text(); const cert = JSON.parse(text); if (!cert.domainName || !cert.publicKey || !cert.privateKey) { DeesToast.show({ message: 'Invalid cert JSON: missing domainName, publicKey, or privateKey.', type: 'error', duration: 4000 }); return; } await appstate.certificateStatePart.dispatchAction( appstate.importCertificateAction, cert, ); DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 }); modal.destroy(); } catch (err) { DeesToast.show({ message: `Import failed: ${err.message}`, type: 'error', duration: 4000 }); } }, }, ], }); }, }, { name: 'Reprovision', iconName: 'lucide:RefreshCw', 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: 'Export', iconName: 'lucide:download', type: ['inRow', 'contextmenu'], actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { const { DeesToast } = await import('@design.estate/dees-catalog'); const cert = actionData.item; try { const response = await appstate.fetchCertificateExport(cert.domain); if (response.success && response.cert) { const safeDomain = cert.domain.replace(/\*/g, '_wildcard'); this.downloadJsonFile(`${safeDomain}.tsclass.cert.json`, response.cert); DeesToast.show({ message: `Certificate exported for ${cert.domain}`, type: 'success', duration: 3000 }); } else { DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 }); } } catch (err) { DeesToast.show({ message: `Export failed: ${err.message}`, type: 'error', duration: 4000 }); } }, }, { name: 'Delete', iconName: 'lucide:trash-2', type: ['inRow', 'contextmenu'], actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { const cert = actionData.item; const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); await DeesModal.createAndShow({ heading: `Delete Certificate: ${cert.domain}`, content: html`

Are you sure you want to delete the certificate data for ${cert.domain}?

Note: The certificate may remain in proxy memory until the next restart or reprovisioning.

`, menuOptions: [ { name: 'Delete', iconName: 'lucide:trash-2', action: async (modal) => { try { await appstate.certificateStatePart.dispatchAction( appstate.deleteCertificateAction, cert.domain, ); DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 }); modal.destroy(); } catch (err) { DeesToast.show({ message: `Delete failed: ${err.message}`, type: 'error', duration: 4000 }); } }, }, ], }); }, }, { name: 'View Details', iconName: 'lucide:Search', 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: 'lucide: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 downloadJsonFile(filename: string, data: any): void { const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } 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`; } }