2026-02-13 17:05:33 +00:00
import {
DeesElement ,
html ,
customElement ,
type TemplateResult ,
css ,
state ,
cssManager ,
} from '@design.estate/dees-element' ;
2026-04-08 11:08:18 +00:00
import * as appstate from '../../appstate.js' ;
import * as interfaces from '../../../dist_ts_interfaces/index.js' ;
import { viewHostCss } from '../shared/css.js' ;
2026-02-13 17:05:33 +00:00
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 ( )
2026-03-26 07:40:56 +00:00
accessor certState : appstate.ICertificateState = appstate . certificateStatePart . getState ( ) ! ;
2026-02-13 17:05:33 +00:00
2026-04-08 13:12:20 +00:00
@state ( )
accessor acmeState : appstate.IAcmeConfigState = appstate . acmeConfigStatePart . getState ( ) ! ;
2026-02-13 17:05:33 +00:00
constructor ( ) {
super ( ) ;
2026-04-08 13:12:20 +00:00
const certSub = appstate . certificateStatePart . select ( ) . subscribe ( ( newState ) = > {
2026-02-13 17:05:33 +00:00
this . certState = newState ;
} ) ;
2026-04-08 13:12:20 +00:00
this . rxSubscriptions . push ( certSub ) ;
const acmeSub = appstate . acmeConfigStatePart . select ( ) . subscribe ( ( newState ) = > {
this . acmeState = newState ;
} ) ;
this . rxSubscriptions . push ( acmeSub ) ;
2026-02-13 17:05:33 +00:00
}
async connectedCallback() {
await super . connectedCallback ( ) ;
await appstate . certificateStatePart . dispatchAction ( appstate . fetchCertificateOverviewAction , null ) ;
2026-04-08 13:12:20 +00:00
await appstate . acmeConfigStatePart . dispatchAction ( appstate . fetchAcmeConfigAction , null ) ;
2026-02-13 17:05:33 +00:00
}
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 `
2026-04-08 11:08:18 +00:00
<dees-heading level="3">Certificates</dees-heading>
2026-02-13 17:05:33 +00:00
<div class="certificatesContainer">
${ this . renderStatsTiles ( summary ) }
2026-04-12 19:42:07 +00:00
${ this . renderAcmeSettingsTile ( ) }
2026-02-13 17:05:33 +00:00
${ this . renderCertificateTable ( ) }
</div>
` ;
}
2026-04-12 19:42:07 +00:00
private renderAcmeSettingsTile ( ) : TemplateResult {
2026-04-08 13:12:20 +00:00
const config = this . acmeState . config ;
if ( ! config ) {
return html `
2026-04-12 20:43:57 +00:00
<dees-settings
.heading= ${ 'ACME Settings' }
.description= ${ '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.' }
.actions= ${ [ { name : 'Configure' , action : ( ) = > this . showEditAcmeDialog ( ) } ]}
></dees-settings>
2026-04-08 13:12:20 +00:00
` ;
}
return html `
2026-04-12 20:43:57 +00:00
<dees-settings
.heading= ${ 'ACME Settings' }
.settingsFields= ${ [
{ key : 'email' , label : 'Account email' , value : config.accountEmail || '(not set)' } ,
{ key: 'status', label: 'Status', value: config.enabled ? 'enabled' : 'disabled' },
{ key: 'mode', label: 'Mode', value: config.useProduction ? 'production' : 'staging' },
{ key: 'autoRenew', label: 'Auto-renew', value: config.autoRenew ? 'on' : 'off' },
{ key: 'threshold', label: 'Renewal threshold', value: ` $ { config . renewThresholdDays } days ` },
]}
.actions= ${ [ { name : 'Edit' , action : ( ) = > this . showEditAcmeDialog ( ) } ]}
></dees-settings>
2026-04-08 13:12:20 +00:00
` ;
}
private async showEditAcmeDialog() {
const { DeesModal , DeesToast } = await import ( '@design.estate/dees-catalog' ) ;
const current = this . acmeState . config ;
DeesModal . createAndShow ( {
heading : current ? 'Edit ACME Settings' : 'Configure ACME' ,
content : html `
<dees-form>
<dees-input-text
.key= ${ 'accountEmail' }
.label= ${ 'Account email' }
.value= ${ current ? . accountEmail ? ? '' }
.required= ${ true }
></dees-input-text>
<dees-input-checkbox
.key= ${ 'enabled' }
.label= ${ 'Enabled' }
.value= ${ current ? . enabled ? ? true }
></dees-input-checkbox>
<dees-input-checkbox
.key= ${ 'useProduction' }
2026-04-12 19:42:07 +00:00
.label= ${ "Use Let's Encrypt production" }
.description= ${ 'Uncheck to use the staging environment' }
2026-04-08 13:12:20 +00:00
.value= ${ current ? . useProduction ? ? true }
></dees-input-checkbox>
<dees-input-checkbox
.key= ${ 'autoRenew' }
.label= ${ 'Auto-renew certificates' }
.value= ${ current ? . autoRenew ? ? true }
></dees-input-checkbox>
<dees-input-text
.key= ${ 'renewThresholdDays' }
2026-04-12 19:42:07 +00:00
.label= ${ 'Renewal threshold' }
.description= ${ 'Number of days before expiry to trigger renewal' }
2026-04-08 13:12:20 +00:00
.value= ${ String ( current ? . renewThresholdDays ? ? 30 ) }
></dees-input-text>
</dees-form>
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
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.
</p>
` ,
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 ( ) ;
} ,
} ,
] ,
} ) ;
}
2026-02-13 17:05:33 +00:00
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 }
2026-04-08 07:11:21 +00:00
.showColumnFilters= ${ true }
2026-02-13 17:05:33 +00:00
.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
2026-04-12 19:42:07 +00:00
.key = $ { 'certJsonFile' }
.label = $ { 'Certificate JSON' }
.description = $ { 'Upload a .tsclass.cert.json file' }
.accept = $ { '.json' }
2026-02-17 16:28:33 +00:00
.multiple = $ { false }
2026-04-12 19:42:07 +00:00
.required = $ { true }
2026-02-17 16:28:33 +00:00
> < / d e e s - i n p u t - f i l e u p l o a d >
< / d e e s - f o r m >
` ,
menuOptions: [
{
name: 'Import',
iconName: 'lucide:upload',
2026-03-26 07:40:56 +00:00
action: async (modal: any) => {
2026-02-17 16:28:33 +00:00
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
2026-03-26 07:40:56 +00:00
const form = modal.shadowRoot!.querySelector('dees-form') as any;
2026-02-17 16:28:33 +00:00
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();
2026-03-26 07:40:56 +00:00
} catch (err: unknown) {
DeesToast.show({ message: ` Import failed : $ { ( err as Error ) . message } ` , type: 'error', duration: 4000 });
2026-02-17 16:28:33 +00:00
}
},
},
],
});
},
},
2026-02-13 17:05:33 +00:00
{
name: 'Reprovision',
2026-02-17 10:57:27 +00:00
iconName: 'lucide:RefreshCw',
2026-04-03 10:14:52 +00:00
type: ['inRow', 'contextmenu'],
2026-02-13 17:05:33 +00:00
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;
}
2026-04-03 10:14:52 +00:00
2026-04-03 19:08:46 +00:00
const doReprovision = async (forceRenew = false) => {
2026-04-03 10:14:52 +00:00
await appstate.certificateStatePart.dispatchAction(
appstate.reprovisionCertificateAction,
2026-04-03 19:08:46 +00:00
{ domain: cert.domain, forceRenew },
2026-04-03 10:14:52 +00:00
);
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({
2026-04-03 19:08:46 +00:00
message: forceRenew
? ` Force renewal triggered for $ { cert . domain } `
: ` Reprovisioning triggered for $ { cert . domain } ` ,
2026-04-03 10:14:52 +00:00
type: 'success',
duration: 3000,
});
};
if (cert.status === 'valid') {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Certificate Still Valid',
content: html ` < p style = "margin: 0; line-height: 1.5;" > The certificate for < strong > $ { cert . domain } < / strong > is still valid $ { cert . expiryDate ? ` until ${ new Date ( cert . expiryDate ) . toLocaleDateString ( ) } ` : '' } . Do you want to force renew it now ? < / p > ` ,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Force Renew',
action: async (modalArg: any) => {
await modalArg.destroy();
2026-04-03 19:08:46 +00:00
await doReprovision(true);
2026-04-03 10:14:52 +00:00
},
},
],
});
} else {
await doReprovision();
}
2026-02-13 17:05:33 +00:00
},
},
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 });
}
2026-03-26 07:40:56 +00:00
} catch (err: unknown) {
DeesToast.show({ message: ` Export failed : $ { ( err as Error ) . message } ` , type: 'error', duration: 4000 });
2026-02-17 16:28:33 +00:00
}
},
},
{
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',
2026-03-26 07:40:56 +00:00
action: async (modal: any) => {
2026-02-17 16:28:33 +00:00
try {
await appstate.certificateStatePart.dispatchAction(
appstate.deleteCertificateAction,
cert.domain,
);
DeesToast.show({ message: ` Certificate deleted for $ { cert . domain } ` , type: 'success', duration: 3000 });
modal.destroy();
2026-03-26 07:40:56 +00:00
} catch (err: unknown) {
DeesToast.show({ message: ` Delete failed : $ { ( err as Error ) . message } ` , type: 'error', duration: 4000 });
2026-02-17 16:28:33 +00:00
}
},
},
],
});
},
},
2026-02-13 17:05:33 +00:00
{
name: 'View Details',
2026-02-18 18:47:18 +00:00
iconName: 'fa:magnifyingGlass',
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 ) }
> < / d e e s - d a t a v i e w - c o d e b o x >
< / 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
}