2026-02-13 17:05:33 +00:00
|
|
|
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')};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
.routePills {
|
2026-02-13 17:05:33 +00:00
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
.routePill {
|
2026-02-13 17:05:33 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
.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')};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 17:05:33 +00:00
|
|
|
.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',
|
2026-02-17 10:57:27 +00:00
|
|
|
icon: 'lucide:ShieldHalf',
|
2026-02-13 17:05:33 +00:00
|
|
|
color: '#3b82f6',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'valid',
|
|
|
|
|
title: 'Valid',
|
|
|
|
|
value: summary.valid,
|
|
|
|
|
type: 'number',
|
2026-02-17 10:57:27 +00:00
|
|
|
icon: 'lucide:Check',
|
2026-02-13 17:05:33 +00:00
|
|
|
color: '#22c55e',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'expiring',
|
|
|
|
|
title: 'Expiring Soon',
|
|
|
|
|
value: summary.expiring,
|
|
|
|
|
type: 'number',
|
2026-02-17 10:57:27 +00:00
|
|
|
icon: 'lucide:Clock',
|
2026-02-13 17:05:33 +00:00
|
|
|
color: '#f59e0b',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'problems',
|
|
|
|
|
title: 'Failed / Expired',
|
|
|
|
|
value: summary.failed + summary.expired,
|
|
|
|
|
type: 'number',
|
2026-02-17 10:57:27 +00:00
|
|
|
icon: 'lucide:TriangleAlert',
|
2026-02-13 17:05:33 +00:00
|
|
|
color: '#ef4444',
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return html`
|
|
|
|
|
<dees-statsgrid
|
|
|
|
|
.tiles=${tiles}
|
|
|
|
|
.minTileWidth=${200}
|
|
|
|
|
.gridActions=${[
|
|
|
|
|
{
|
|
|
|
|
name: 'Refresh',
|
2026-02-17 10:57:27 +00:00
|
|
|
iconName: 'lucide:RefreshCw',
|
2026-02-13 17:05:33 +00:00
|
|
|
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) => ({
|
2026-02-15 16:03:13 +00:00
|
|
|
Domain: cert.domain,
|
|
|
|
|
Routes: this.renderRoutePills(cert.routeNames),
|
2026-02-13 17:05:33 +00:00
|
|
|
Status: this.renderStatusBadge(cert.status),
|
|
|
|
|
Source: this.renderSourceBadge(cert.source),
|
|
|
|
|
Expires: this.renderExpiry(cert.expiryDate),
|
2026-02-15 16:03:13 +00:00
|
|
|
Error: cert.backoffInfo
|
|
|
|
|
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
|
|
|
|
|
: cert.error
|
|
|
|
|
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
|
|
|
|
|
: '',
|
2026-02-13 17:05:33 +00:00
|
|
|
})}
|
|
|
|
|
.dataActions=${[
|
2026-02-17 16:28:33 +00:00
|
|
|
{
|
|
|
|
|
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`
|
|
|
|
|
<dees-form>
|
|
|
|
|
<dees-input-fileupload
|
|
|
|
|
key="certJsonFile"
|
|
|
|
|
label="Certificate JSON (.tsclass.cert.json)"
|
|
|
|
|
accept=".json"
|
|
|
|
|
.multiple=${false}
|
|
|
|
|
required
|
|
|
|
|
></dees-input-fileupload>
|
|
|
|
|
</dees-form>
|
|
|
|
|
`,
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-13 17:05:33 +00:00
|
|
|
{
|
|
|
|
|
name: 'Reprovision',
|
2026-02-17 10:57:27 +00:00
|
|
|
iconName: 'lucide:RefreshCw',
|
2026-02-13 17:05:33 +00:00
|
|
|
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,
|
2026-02-15 16:03:13 +00:00
|
|
|
cert.domain,
|
2026-02-13 17:05:33 +00:00
|
|
|
);
|
|
|
|
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
|
|
|
|
DeesToast.show({
|
2026-02-15 16:03:13 +00:00
|
|
|
message: `Reprovisioning triggered for ${cert.domain}`,
|
2026-02-13 17:05:33 +00:00
|
|
|
type: 'success',
|
|
|
|
|
duration: 3000,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-17 16:28:33 +00:00
|
|
|
{
|
|
|
|
|
name: 'Export',
|
|
|
|
|
iconName: 'lucide:download',
|
2026-02-17 17:49:12 +00:00
|
|
|
type: ['inRow', 'contextmenu'],
|
2026-02-17 16:28:33 +00:00
|
|
|
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',
|
2026-02-17 17:49:12 +00:00
|
|
|
type: ['inRow', 'contextmenu'],
|
2026-02-17 16:28:33 +00:00
|
|
|
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`
|
|
|
|
|
<div style="padding: 20px; font-size: 14px;">
|
|
|
|
|
<p>Are you sure you want to delete the certificate data for <strong>${cert.domain}</strong>?</p>
|
|
|
|
|
<p style="color: #f59e0b; margin-top: 12px;">Note: The certificate may remain in proxy memory until the next restart or reprovisioning.</p>
|
|
|
|
|
</div>
|
|
|
|
|
`,
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-13 17:05:33 +00:00
|
|
|
{
|
|
|
|
|
name: 'View Details',
|
2026-02-17 10:57:27 +00:00
|
|
|
iconName: 'lucide:Search',
|
2026-02-13 17:05:33 +00:00
|
|
|
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({
|
2026-02-15 16:03:13 +00:00
|
|
|
heading: `Certificate: ${cert.domain}`,
|
2026-02-13 17:05:33 +00:00
|
|
|
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: [
|
|
|
|
|
{
|
2026-02-15 16:03:13 +00:00
|
|
|
name: 'Copy Domain',
|
2026-02-17 10:57:27 +00:00
|
|
|
iconName: 'lucide:Copy',
|
2026-02-13 17:05:33 +00:00
|
|
|
action: async () => {
|
2026-02-15 16:03:13 +00:00
|
|
|
await navigator.clipboard.writeText(cert.domain);
|
2026-02-13 17:05:33 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
heading1="Certificate Status"
|
2026-02-15 16:03:13 +00:00
|
|
|
heading2="TLS certificates by domain"
|
2026-02-13 17:05:33 +00:00
|
|
|
searchable
|
|
|
|
|
.pagination=${true}
|
|
|
|
|
.paginationSize=${50}
|
|
|
|
|
dataName="certificate"
|
|
|
|
|
></dees-table>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:28:33 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
private renderRoutePills(routeNames: string[]): TemplateResult {
|
2026-02-13 17:05:33 +00:00
|
|
|
const maxShow = 3;
|
2026-02-15 16:03:13 +00:00
|
|
|
const visible = routeNames.slice(0, maxShow);
|
|
|
|
|
const remaining = routeNames.length - maxShow;
|
2026-02-13 17:05:33 +00:00
|
|
|
|
|
|
|
|
return html`
|
2026-02-15 16:03:13 +00:00
|
|
|
<span class="routePills">
|
|
|
|
|
${visible.map((r) => html`<span class="routePill">${r}</span>`)}
|
2026-02-13 17:05:33 +00:00
|
|
|
${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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
2026-02-15 16:03:13 +00:00
|
|
|
|
|
|
|
|
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`;
|
|
|
|
|
}
|
2026-02-13 17:05:33 +00:00
|
|
|
}
|