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()!;
@state()
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
constructor() {
super();
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
this.certState = newState;
});
this.rxSubscriptions.push(certSub);
const acmeSub = appstate.acmeConfigStatePart.select().subscribe((newState) => {
this.acmeState = newState;
});
this.rxSubscriptions.push(acmeSub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.certificatesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.acmeCard {
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 8px;
}
.acmeCard.acmeCardEmpty {
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border-color: ${cssManager.bdTheme('#fde68a', '#78350f')};
}
.acmeCardHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.acmeCardTitle {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px 24px;
}
.acmeField {
display: flex;
flex-direction: column;
gap: 2px;
}
.acmeLabel {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.acmeValue {
font-size: 13px;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeEmptyHint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
}
.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`
No ACME configuration yet. Click Configure to set up automated TLS certificate issuance via Let's Encrypt. You'll also need at least one DNS provider under Domains > Providers.
Most fields take effect on the next dcrouter restart (SmartAcme is instantiated once at startup). Changing the account email creates a new Let's Encrypt account — only do this if you know what you're doing.
`, menuOptions: [ { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, { name: 'Save', action: async (modalArg: any) => { const form = modalArg.shadowRoot ?.querySelector('.content') ?.querySelector('dees-form'); if (!form) return; const data = await form.collectFormData(); const email = String(data.accountEmail ?? '').trim(); if (!email) { DeesToast.show({ message: 'Account email is required', type: 'warning', duration: 2500, }); return; } const threshold = parseInt(String(data.renewThresholdDays ?? '30'), 10); await appstate.acmeConfigStatePart.dispatchAction(appstate.updateAcmeConfigAction, { accountEmail: email, enabled: Boolean(data.enabled), useProduction: Boolean(data.useProduction), autoRenew: Boolean(data.autoRenew), renewThresholdDays: Number.isFinite(threshold) ? threshold : 30, }); modalArg.destroy(); }, }, ], }); } 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`The certificate for ${cert.domain} is still valid${cert.expiryDate ? ` until ${new Date(cert.expiryDate).toLocaleDateString()}` : ''}. Do you want to force renew it now?
`, menuOptions: [ { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, { name: 'Force Renew', action: async (modalArg: any) => { await modalArg.destroy(); await doReprovision(true); }, }, ], }); } else { await doReprovision(); } }, }, { 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: unknown) { DeesToast.show({ message: `Export failed: ${(err as Error).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.