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 ( )
2026-03-26 07:40:56 +00:00
accessor certState : appstate.ICertificateState = appstate . certificateStatePart . getState ( ) ! ;
2026-02-13 17:05:33 +00:00
constructor ( ) {
super ( ) ;
2026-03-27 18:46:11 +00:00
const sub = appstate . certificateStatePart . select ( ) . subscribe ( ( newState ) = > {
2026-02-13 17:05:33 +00:00
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 10 px ;
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 8 px ;
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 8 px ;
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 6 px ;
}
. 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 6 px ;
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 < / o p s - s e c t i o n h e a d i n g >
< 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
) ;
} ,
} ,
] }
> < / d e e s - s t a t s g r i d >
` ;
}
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
> < / 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
const doReprovision = async ( ) = > {
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 ,
} ) ;
} ;
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 ( ) ;
await doReprovision ( ) ;
} ,
} ,
] ,
} ) ;
} 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"
> < / d e e s - t a b l e >
` ;
}
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
}