feat(certificates): add certificate overview and reprovisioning in ops UI and API; track SmartProxy certificate events
This commit is contained in:
355
ts_web/elements/ops-view-certificates.ts
Normal file
355
ts_web/elements/ops-view-certificates.ts
Normal file
@@ -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`
|
||||
<ops-sectionheading>Certificates</ops-sectionheading>
|
||||
|
||||
<div class="certificatesContainer">
|
||||
${this.renderStatsTiles(summary)}
|
||||
${this.renderCertificateTable()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'arrowsRotate',
|
||||
action: async () => {
|
||||
await appstate.certificateStatePart.dispatchAction(
|
||||
appstate.fetchCertificateOverviewAction,
|
||||
null
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCertificateTable(): TemplateResult {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.certState.certificates}
|
||||
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
||||
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`<span class="errorText" title="${cert.error}">${cert.error}</span>`
|
||||
: '',
|
||||
})}
|
||||
.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`
|
||||
<div style="padding: 20px;">
|
||||
<dees-dataview-codebox
|
||||
.heading=${'Certificate Details'}
|
||||
progLang="json"
|
||||
.codeToDisplay=${JSON.stringify(cert, null, 2)}
|
||||
></dees-dataview-codebox>
|
||||
</div>
|
||||
`,
|
||||
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"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDomainPills(domains: string[]): TemplateResult {
|
||||
const maxShow = 3;
|
||||
const visible = domains.slice(0, maxShow);
|
||||
const remaining = domains.length - maxShow;
|
||||
|
||||
return html`
|
||||
<span class="domainPills">
|
||||
${visible.map((d) => html`<span class="domainPill">${d}</span>`)}
|
||||
${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(status: interfaces.requests.TCertificateStatus): TemplateResult {
|
||||
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||
}
|
||||
|
||||
private renderSourceBadge(source: interfaces.requests.TCertificateSource): TemplateResult {
|
||||
const labels: Record<string, string> = {
|
||||
acme: 'ACME',
|
||||
'provision-function': 'Custom',
|
||||
static: 'Static',
|
||||
none: 'None',
|
||||
};
|
||||
return html`<span class="sourceBadge">${labels[source] || source}</span>`;
|
||||
}
|
||||
|
||||
private renderExpiry(expiryDate?: string): TemplateResult {
|
||||
if (!expiryDate) {
|
||||
return html`<span style="color: ${cssManager.bdTheme('#9ca3af', '#4b5563')}">--</span>`;
|
||||
}
|
||||
|
||||
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`
|
||||
<span class="expiryInfo">
|
||||
${dateStr} <span class="daysLeft ${daysClass}">${daysText}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user