2026-04-08 07:45:26 +00:00
import * as appstate from '../../appstate.js' ;
import * as interfaces from '../../../dist_ts_interfaces/index.js' ;
2026-04-08 08:24:55 +00:00
import { viewHostCss } from '../shared/css.js' ;
2026-02-23 12:40:26 +00:00
import { type IStatsTile } from '@design.estate/dees-catalog' ;
import {
DeesElement ,
css ,
cssManager ,
customElement ,
html ,
state ,
type TemplateResult ,
} from '@design.estate/dees-element' ;
2026-04-04 21:23:16 +00:00
// TLS dropdown options shared by create and edit dialogs
const tlsModeOptions = [
{ key : 'none' , option : '(none — no TLS)' } ,
{ key : 'passthrough' , option : 'Passthrough' } ,
{ key : 'terminate' , option : 'Terminate' } ,
{ key : 'terminate-and-reencrypt' , option : 'Terminate & Re-encrypt' } ,
] ;
const tlsCertOptions = [
{ key : 'auto' , option : 'Auto (ACME/Let\'s Encrypt)' } ,
{ key : 'custom' , option : 'Custom certificate' } ,
] ;
/**
* Toggle TLS form field visibility based on selected TLS mode and certificate type.
*/
function setupTlsVisibility ( formEl : any ) {
const updateVisibility = async ( ) = > {
const data = await formEl . collectFormData ( ) ;
const contentEl = formEl . closest ( '.content' ) || formEl . parentElement ;
if ( ! contentEl ) return ;
const tlsModeValue = data . tlsMode ;
const modeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key ;
const needsCert = modeKey === 'terminate' || modeKey === 'terminate-and-reencrypt' ;
const certGroup = contentEl . querySelector ( '.tlsCertificateGroup' ) as HTMLElement ;
if ( certGroup ) certGroup . style . display = needsCert ? 'flex' : 'none' ;
const tlsCertValue = data . tlsCertificate ;
const certKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key ;
const customGroup = contentEl . querySelector ( '.tlsCustomCertGroup' ) as HTMLElement ;
if ( customGroup ) customGroup . style . display = ( needsCert && certKey === 'custom' ) ? 'flex' : 'none' ;
} ;
formEl . changeSubject . subscribe ( ( ) = > updateVisibility ( ) ) ;
updateVisibility ( ) ;
}
2026-02-23 12:40:26 +00:00
@customElement ( 'ops-view-routes' )
export class OpsViewRoutes extends DeesElement {
@state ( ) accessor routeState : appstate.IRouteManagementState = {
mergedRoutes : [ ] ,
warnings : [ ] ,
apiTokens : [ ] ,
isLoading : false ,
error : null ,
lastUpdated : 0 ,
} ;
2026-04-02 17:27:05 +00:00
@state ( ) accessor profilesTargetsState : appstate.IProfilesTargetsState = {
profiles : [ ] ,
targets : [ ] ,
isLoading : false ,
error : null ,
lastUpdated : 0 ,
} ;
2026-02-23 12:40:26 +00:00
constructor ( ) {
super ( ) ;
const sub = appstate . routeManagementStatePart
. select ( ( s ) = > s )
. subscribe ( ( routeState ) = > {
this . routeState = routeState ;
} ) ;
this . rxSubscriptions . push ( sub ) ;
2026-04-02 17:27:05 +00:00
const ptSub = appstate . profilesTargetsStatePart
. select ( ( s ) = > s )
. subscribe ( ( ptState ) = > {
this . profilesTargetsState = ptState ;
} ) ;
this . rxSubscriptions . push ( ptSub ) ;
2026-02-23 12:40:26 +00:00
// Re-fetch routes when user logs in (fixes race condition where
// the view is created before authentication completes)
const loginSub = appstate . loginStatePart
. select ( ( s ) = > s . isLoggedIn )
. subscribe ( ( isLoggedIn ) = > {
if ( isLoggedIn ) {
appstate . routeManagementStatePart . dispatchAction ( appstate . fetchMergedRoutesAction , null ) ;
2026-04-02 17:27:05 +00:00
appstate . profilesTargetsStatePart . dispatchAction ( appstate . fetchProfilesAndTargetsAction , null ) ;
2026-02-23 12:40:26 +00:00
}
} ) ;
this . rxSubscriptions . push ( loginSub ) ;
}
public static styles = [
cssManager . defaultStyles ,
2026-04-08 08:24:55 +00:00
viewHostCss ,
2026-02-23 12:40:26 +00:00
css `
.routesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.warnings-bar {
background: ${ cssManager . bdTheme ( 'rgba(255, 170, 0, 0.08)' , 'rgba(255, 170, 0, 0.1)' ) } ;
border: 1px solid ${ cssManager . bdTheme ( 'rgba(255, 170, 0, 0.25)' , 'rgba(255, 170, 0, 0.3)' ) } ;
border-radius: 8px;
padding: 12px 16px;
}
.warning-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
color: ${ cssManager . bdTheme ( '#b45309' , '#fa0' ) } ;
}
.warning-icon {
flex-shrink: 0;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: ${ cssManager . bdTheme ( '#6b7280' , '#666' ) } ;
}
.empty-state p {
margin: 8px 0;
}
` ,
] ;
public render ( ) : TemplateResult {
const { mergedRoutes , warnings } = this . routeState ;
const disabledCount = mergedRoutes . filter ( ( mr ) = > ! mr . enabled ) . length ;
2026-04-13 17:38:23 +00:00
const configCount = mergedRoutes . filter ( ( mr ) = > mr . origin !== 'api' ) . length ;
const apiCount = mergedRoutes . filter ( ( mr ) = > mr . origin === 'api' ) . length ;
2026-02-23 12:40:26 +00:00
const statsTiles : IStatsTile [ ] = [
{
id : 'totalRoutes' ,
title : 'Total Routes' ,
type : 'number' ,
value : mergedRoutes.length ,
icon : 'lucide:route' ,
description : 'All configured routes' ,
color : '#3b82f6' ,
} ,
{
2026-04-13 17:38:23 +00:00
id : 'configRoutes' ,
title : 'From Config' ,
2026-02-23 12:40:26 +00:00
type : 'number' ,
2026-04-13 17:38:23 +00:00
value : configCount ,
icon : 'lucide:settings' ,
description : 'Seeded from config/email/DNS' ,
2026-02-23 12:40:26 +00:00
color : '#8b5cf6' ,
} ,
{
2026-04-13 17:38:23 +00:00
id : 'apiRoutes' ,
title : 'API Created' ,
2026-02-23 12:40:26 +00:00
type : 'number' ,
2026-04-13 17:38:23 +00:00
value : apiCount ,
2026-02-23 12:40:26 +00:00
icon : 'lucide:code' ,
description : 'Routes added via API' ,
color : '#0ea5e9' ,
} ,
{
id : 'disabled' ,
title : 'Disabled' ,
type : 'number' ,
value : disabledCount ,
icon : 'lucide:pauseCircle' ,
description : 'Currently disabled routes' ,
color : disabledCount > 0 ? '#ef4444' : '#6b7280' ,
} ,
] ;
// Map merged routes to sz-route-list-view format
const szRoutes = mergedRoutes . map ( ( mr ) = > {
const tags = [ . . . ( mr . route . tags || [ ] ) ] ;
2026-04-13 17:38:23 +00:00
tags . push ( mr . origin ) ;
2026-02-23 12:40:26 +00:00
if ( ! mr . enabled ) tags . push ( 'disabled' ) ;
return {
. . . mr . route ,
enabled : mr.enabled ,
tags ,
2026-04-13 17:38:23 +00:00
id : mr.id || mr . route . name || undefined ,
2026-04-02 17:27:05 +00:00
metadata : mr.metadata ,
2026-02-23 12:40:26 +00:00
} ;
} ) ;
return html `
2026-04-08 11:08:18 +00:00
<dees-heading level="3">Route Management</dees-heading>
2026-02-23 12:40:26 +00:00
<div class="routesContainer">
<dees-statsgrid
.tiles= ${ statsTiles }
.gridActions= ${ [
{
name : 'Add Route' ,
iconName : 'lucide:plus' ,
action : ( ) = > this . showCreateRouteDialog ( ) ,
} ,
{
name: 'Refresh',
iconName: 'lucide:refreshCw',
action: () => this.refreshData(),
},
]}
></dees-statsgrid>
${ warnings . length > 0
? html `
<div class="warnings-bar">
${ warnings . map (
( w ) = > html `
<div class="warning-item">
<span class="warning-icon">⚠</span>
<span> ${ w . message } </span>
</div>
` ,
) }
</div>
`
: '' }
${ szRoutes . length > 0
? html `
<sz-route-list-view
.routes= ${ szRoutes }
@route-click= ${ ( e : CustomEvent ) = > this . handleRouteClick ( e ) }
2026-04-02 22:37:49 +00:00
@route-edit= ${ ( e : CustomEvent ) = > this . handleRouteEdit ( e ) }
@route-delete= ${ ( e : CustomEvent ) = > this . handleRouteDelete ( e ) }
2026-02-23 12:40:26 +00:00
></sz-route-list-view>
`
: html `
<div class="empty-state">
<p>No routes configured</p>
2026-04-13 17:38:23 +00:00
<p>Add a route to get started.</p>
2026-02-23 12:40:26 +00:00
</div>
` }
</div>
` ;
}
private async handleRouteClick ( e : CustomEvent ) {
const clickedRoute = e . detail ;
if ( ! clickedRoute ) return ;
// Find the corresponding merged route
const merged = this . routeState . mergedRoutes . find (
( mr ) = > mr . route . name === clickedRoute . name ,
) ;
if ( ! merged ) return ;
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
2026-04-13 17:38:23 +00:00
const meta = merged . metadata ;
await DeesModal . createAndShow ( {
heading : ` Route: ${ merged . route . name } ` ,
content : html `
<div style="color: #ccc; padding: 8px 0;">
<p>Origin: <strong style="color: #0af;"> ${ merged . origin } </strong></p>
<p>Status: <strong> ${ merged . enabled ? 'Enabled' : 'Disabled' } </strong></p>
<p>ID: <code style="color: #888;"> ${ merged . id } </code></p>
${ meta ? . sourceProfileName ? html ` <p>Source Profile: <strong style="color: #a78bfa;"> ${ meta . sourceProfileName } </strong></p> ` : '' }
${ meta ? . networkTargetName ? html ` <p>Network Target: <strong style="color: #a78bfa;"> ${ meta . networkTargetName } </strong></p> ` : '' }
</div>
` ,
menuOptions : [
{
name : merged.enabled ? 'Disable' : 'Enable' ,
iconName : merged.enabled ? 'lucide:pause' : 'lucide:play' ,
action : async ( modalArg : any ) = > {
await appstate . routeManagementStatePart . dispatchAction (
appstate . toggleRouteAction ,
{ id : merged.id , enabled : ! merged . enabled } ,
) ;
await modalArg . destroy ( ) ;
2026-02-23 12:40:26 +00:00
} ,
2026-04-13 17:38:23 +00:00
} ,
{
name : 'Edit' ,
iconName : 'lucide:pencil' ,
action : async ( modalArg : any ) = > {
await modalArg . destroy ( ) ;
this . showEditRouteDialog ( merged ) ;
2026-02-23 12:40:26 +00:00
} ,
2026-04-13 17:38:23 +00:00
} ,
{
name : 'Delete' ,
iconName : 'lucide:trash-2' ,
action : async ( modalArg : any ) = > {
await appstate . routeManagementStatePart . dispatchAction (
appstate . deleteRouteAction ,
merged . id ,
) ;
await modalArg . destroy ( ) ;
2026-02-23 12:40:26 +00:00
} ,
2026-04-13 17:38:23 +00:00
} ,
{
name : 'Close' ,
iconName : 'lucide:x' ,
action : async ( modalArg : any ) = > await modalArg . destroy ( ) ,
} ,
] ,
} ) ;
2026-02-23 12:40:26 +00:00
}
2026-04-02 22:37:49 +00:00
private async handleRouteEdit ( e : CustomEvent ) {
const clickedRoute = e . detail ;
if ( ! clickedRoute ) return ;
const merged = this . routeState . mergedRoutes . find (
( mr ) = > mr . route . name === clickedRoute . name ,
) ;
2026-04-13 17:38:23 +00:00
if ( ! merged ) return ;
2026-04-02 22:37:49 +00:00
this . showEditRouteDialog ( merged ) ;
}
private async handleRouteDelete ( e : CustomEvent ) {
const clickedRoute = e . detail ;
if ( ! clickedRoute ) return ;
const merged = this . routeState . mergedRoutes . find (
( mr ) = > mr . route . name === clickedRoute . name ,
) ;
2026-04-13 17:38:23 +00:00
if ( ! merged ) return ;
2026-04-02 22:37:49 +00:00
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
await DeesModal . createAndShow ( {
heading : ` Delete Route: ${ merged . route . name } ` ,
content : html `
<div style="color: #ccc; padding: 8px 0;">
<p>Are you sure you want to delete this route? This action cannot be undone.</p>
</div>
` ,
menuOptions : [
{
name : 'Cancel' ,
iconName : 'lucide:x' ,
action : async ( modalArg : any ) = > await modalArg . destroy ( ) ,
} ,
{
name : 'Delete' ,
iconName : 'lucide:trash-2' ,
action : async ( modalArg : any ) = > {
await appstate . routeManagementStatePart . dispatchAction (
appstate . deleteRouteAction ,
2026-04-13 17:38:23 +00:00
merged . id ,
2026-04-02 22:37:49 +00:00
) ;
await modalArg . destroy ( ) ;
} ,
} ,
] ,
} ) ;
}
private async showEditRouteDialog ( merged : interfaces.data.IMergedRoute ) {
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
const profiles = this . profilesTargetsState . profiles ;
const targets = this . profilesTargetsState . targets ;
const profileOptions = [
{ key : '' , option : '(none — inline security)' } ,
. . . profiles . map ( ( p ) = > ( {
key : p.id ,
option : ` ${ p . name } ${ p . description ? ' — ' + p . description : '' } ` ,
} ) ) ,
] ;
const targetOptions = [
{ key : '' , option : '(none — inline target)' } ,
. . . targets . map ( ( t ) = > ( {
key : t.id ,
option : ` ${ t . name } ( ${ Array . isArray ( t . host ) ? t . host . join ( ',' ) : t . host } : ${ t . port } ) ` ,
} ) ) ,
] ;
const route = merged . route ;
const currentPorts = Array . isArray ( route . match . ports )
? route . match . ports . map ( ( p : any ) = > typeof p === 'number' ? String ( p ) : ` ${ p . from } - ${ p . to } ` ) . join ( ', ' )
: String ( route . match . ports ) ;
2026-04-02 22:55:57 +00:00
const currentDomains : string [ ] = route . match . domains
? ( Array . isArray ( route . match . domains ) ? route . match . domains : [ route . match . domains ] )
: [ ] ;
2026-04-02 22:37:49 +00:00
const firstTarget = route . action . targets ? . [ 0 ] ;
const currentTargetHost = firstTarget
? ( Array . isArray ( firstTarget . host ) ? firstTarget . host [ 0 ] : firstTarget . host )
: '' ;
const currentTargetPort = firstTarget ? . port != null ? String ( firstTarget . port ) : '' ;
2026-04-04 21:23:16 +00:00
// Compute current TLS state for pre-population
const currentTls = ( route . action as any ) . tls ;
const currentTlsMode = currentTls ? . mode || 'none' ;
const currentTlsCert = currentTls
? ( currentTls . certificate === 'auto' || ! currentTls . certificate ? 'auto' : 'custom' )
: 'auto' ;
const currentCustomKey = ( typeof currentTls ? . certificate === 'object' ) ? currentTls . certificate . key : '' ;
const currentCustomCert = ( typeof currentTls ? . certificate === 'object' ) ? currentTls . certificate . cert : '' ;
const needsCert = currentTlsMode === 'terminate' || currentTlsMode === 'terminate-and-reencrypt' ;
const isCustom = currentTlsCert === 'custom' ;
const editModal = await DeesModal . createAndShow ( {
2026-04-02 22:37:49 +00:00
heading : ` Edit Route: ${ route . name } ` ,
content : html `
<dees-form>
<dees-input-text .key= ${ 'name' } .label= ${ 'Route Name' } .value= ${ route . name || '' } .required= ${ true } ></dees-input-text>
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'ports' } .label= ${ 'Ports' } .description= ${ 'Comma-separated, e.g. 80, 443' } .value= ${ currentPorts } .required= ${ true } ></dees-input-text>
2026-04-02 22:55:57 +00:00
<dees-input-list .key= ${ 'domains' } .label= ${ 'Domains' } .placeholder= ${ 'Add domain...' } .value= ${ currentDomains } ></dees-input-list>
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'priority' } .label= ${ 'Priority' } .description= ${ 'Higher values are matched first' } .value= ${ route . priority != null ? String ( route . priority ) : '' } ></dees-input-text>
2026-04-05 00:37:37 +00:00
<dees-input-dropdown .key= ${ 'sourceProfileRef' } .label= ${ 'Source Profile' } .options= ${ profileOptions } .selectedOption= ${ profileOptions . find ( ( o ) = > o . key === ( merged . metadata ? . sourceProfileRef || '' ) ) || null } ></dees-input-dropdown>
2026-04-04 11:00:03 +00:00
<dees-input-dropdown .key= ${ 'networkTargetRef' } .label= ${ 'Network Target' } .options= ${ targetOptions } .selectedOption= ${ targetOptions . find ( ( o ) = > o . key === ( merged . metadata ? . networkTargetRef || '' ) ) || null } ></dees-input-dropdown>
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'targetHost' } .label= ${ 'Target Host' } .description= ${ 'Used when no network target is selected' } .value= ${ currentTargetHost } ></dees-input-text>
<dees-input-text .key= ${ 'targetPort' } .label= ${ 'Target Port' } .description= ${ 'Used when no network target is selected' } .value= ${ currentTargetPort } ></dees-input-text>
2026-04-04 21:23:16 +00:00
<dees-input-dropdown .key= ${ 'tlsMode' } .label= ${ 'TLS Mode' } .options= ${ tlsModeOptions } .selectedOption= ${ tlsModeOptions . find ( ( o ) = > o . key === currentTlsMode ) || tlsModeOptions [ 0 ] } ></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: ${ needsCert ? 'flex' : 'none' } ; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key= ${ 'tlsCertificate' } .label= ${ 'Certificate' } .options= ${ tlsCertOptions } .selectedOption= ${ tlsCertOptions . find ( ( o ) = > o . key === currentTlsCert ) || tlsCertOptions [ 0 ] } ></dees-input-dropdown>
<div class="tlsCustomCertGroup" style="display: ${ needsCert && isCustom ? 'flex' : 'none' } ; flex-direction: column; gap: 16px;">
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'tlsCertKey' } .label= ${ 'Private Key' } .description= ${ 'PEM-encoded private key' } .value= ${ currentCustomKey } ></dees-input-text>
<dees-input-text .key= ${ 'tlsCertCert' } .label= ${ 'Certificate' } .description= ${ 'PEM-encoded certificate' } .value= ${ currentCustomCert } ></dees-input-text>
2026-04-04 21:23:16 +00:00
</div>
</div>
2026-04-02 22:37:49 +00:00
</dees-form>
` ,
menuOptions : [
{
name : 'Cancel' ,
iconName : 'lucide:x' ,
action : async ( modalArg : any ) = > await modalArg . destroy ( ) ,
} ,
{
name : 'Save' ,
iconName : 'lucide:check' ,
action : async ( modalArg : any ) = > {
const form = modalArg . shadowRoot ? . querySelector ( '.content' ) ? . querySelector ( 'dees-form' ) ;
if ( ! form ) return ;
const formData = await form . collectFormData ( ) ;
if ( ! formData . name || ! formData . ports ) return ;
const ports = formData . ports . split ( ',' ) . map ( ( p : string ) = > parseInt ( p . trim ( ) , 10 ) ) . filter ( ( p : number ) = > ! isNaN ( p ) ) ;
2026-04-02 22:55:57 +00:00
const domains : string [ ] = Array . isArray ( formData . domains )
? formData . domains . filter ( Boolean )
: [ ] ;
const priority = formData . priority ? parseInt ( formData . priority , 10 ) : undefined ;
2026-04-02 22:37:49 +00:00
const updatedRoute : any = {
name : formData.name ,
match : {
ports ,
2026-04-02 22:55:57 +00:00
. . . ( domains . length > 0 ? { domains } : { } ) ,
2026-04-02 22:37:49 +00:00
} ,
action : {
type : 'forward' ,
targets : [
{
host : formData.targetHost || 'localhost' ,
port : parseInt ( formData . targetPort , 10 ) || 443 ,
} ,
] ,
} ,
2026-04-02 22:55:57 +00:00
. . . ( priority != null && ! isNaN ( priority ) ? { priority } : { } ) ,
2026-04-02 22:37:49 +00:00
} ;
2026-04-04 21:23:16 +00:00
// Build TLS config from form
const tlsModeValue = formData . tlsMode as any ;
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key ;
if ( tlsModeKey && tlsModeKey !== 'none' ) {
const tls : any = { mode : tlsModeKey } ;
if ( tlsModeKey !== 'passthrough' ) {
const tlsCertValue = formData . tlsCertificate as any ;
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key ;
if ( tlsCertKey === 'custom' && formData . tlsCertKey && formData . tlsCertCert ) {
tls . certificate = { key : formData.tlsCertKey , cert : formData.tlsCertCert } ;
} else {
tls . certificate = 'auto' ;
}
}
updatedRoute . action . tls = tls ;
} else {
updatedRoute . action . tls = null ; // explicit removal
}
2026-04-02 22:37:49 +00:00
const metadata : any = { } ;
2026-04-05 00:37:37 +00:00
const profileRefValue = formData . sourceProfileRef as any ;
2026-04-04 11:00:03 +00:00
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key ;
if ( profileKey ) {
2026-04-05 00:37:37 +00:00
metadata . sourceProfileRef = profileKey ;
2026-04-02 22:37:49 +00:00
}
2026-04-04 11:00:03 +00:00
const targetRefValue = formData . networkTargetRef as any ;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key ;
if ( targetKey ) {
metadata . networkTargetRef = targetKey ;
2026-04-02 22:37:49 +00:00
}
await appstate . routeManagementStatePart . dispatchAction (
appstate . updateRouteAction ,
{
2026-04-13 17:38:23 +00:00
id : merged.id ,
2026-04-02 22:37:49 +00:00
route : updatedRoute ,
metadata : Object.keys ( metadata ) . length > 0 ? metadata : undefined ,
} ,
) ;
await modalArg . destroy ( ) ;
} ,
} ,
] ,
} ) ;
2026-04-04 21:23:16 +00:00
// Setup conditional TLS field visibility after modal renders
const editForm = editModal ? . shadowRoot ? . querySelector ( '.content' ) ? . querySelector ( 'dees-form' ) as any ;
if ( editForm ) {
await editForm . updateComplete ;
setupTlsVisibility ( editForm ) ;
}
2026-04-02 22:37:49 +00:00
}
2026-02-23 12:40:26 +00:00
private async showCreateRouteDialog() {
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
2026-04-02 17:27:05 +00:00
const profiles = this . profilesTargetsState . profiles ;
const targets = this . profilesTargetsState . targets ;
// Build dropdown options for profiles and targets
const profileOptions = [
{ key : '' , option : '(none — inline security)' } ,
. . . profiles . map ( ( p ) = > ( {
key : p.id ,
option : ` ${ p . name } ${ p . description ? ' — ' + p . description : '' } ` ,
} ) ) ,
] ;
const targetOptions = [
{ key : '' , option : '(none — inline target)' } ,
. . . targets . map ( ( t ) = > ( {
key : t.id ,
option : ` ${ t . name } ( ${ Array . isArray ( t . host ) ? t . host . join ( ',' ) : t . host } : ${ t . port } ) ` ,
} ) ) ,
] ;
2026-02-23 12:40:26 +00:00
2026-04-04 21:23:16 +00:00
const createModal = await DeesModal . createAndShow ( {
2026-04-13 17:38:23 +00:00
heading : 'Add Route' ,
2026-02-23 12:40:26 +00:00
content : html `
<dees-form>
<dees-input-text .key= ${ 'name' } .label= ${ 'Route Name' } .required= ${ true } ></dees-input-text>
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'ports' } .label= ${ 'Ports' } .description= ${ 'Comma-separated, e.g. 80, 443' } .required= ${ true } ></dees-input-text>
2026-04-02 22:55:57 +00:00
<dees-input-list .key= ${ 'domains' } .label= ${ 'Domains' } .placeholder= ${ 'Add domain...' } ></dees-input-list>
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'priority' } .label= ${ 'Priority' } .description= ${ 'Higher values are matched first' } ></dees-input-text>
2026-04-05 00:37:37 +00:00
<dees-input-dropdown .key= ${ 'sourceProfileRef' } .label= ${ 'Source Profile' } .options= ${ profileOptions } ></dees-input-dropdown>
2026-04-04 11:00:03 +00:00
<dees-input-dropdown .key= ${ 'networkTargetRef' } .label= ${ 'Network Target' } .options= ${ targetOptions } ></dees-input-dropdown>
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'targetHost' } .label= ${ 'Target Host' } .description= ${ 'Used when no network target is selected' } .value= ${ 'localhost' } ></dees-input-text>
<dees-input-text .key= ${ 'targetPort' } .label= ${ 'Target Port' } .description= ${ 'Used when no network target is selected' } ></dees-input-text>
2026-04-04 21:23:16 +00:00
<dees-input-dropdown .key= ${ 'tlsMode' } .label= ${ 'TLS Mode' } .options= ${ tlsModeOptions } .selectedOption= ${ tlsModeOptions [ 0 ] } ></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key= ${ 'tlsCertificate' } .label= ${ 'Certificate' } .options= ${ tlsCertOptions } .selectedOption= ${ tlsCertOptions [ 0 ] } ></dees-input-dropdown>
<div class="tlsCustomCertGroup" style="display: none; flex-direction: column; gap: 16px;">
2026-04-12 19:42:07 +00:00
<dees-input-text .key= ${ 'tlsCertKey' } .label= ${ 'Private Key' } .description= ${ 'PEM-encoded private key' } ></dees-input-text>
<dees-input-text .key= ${ 'tlsCertCert' } .label= ${ 'Certificate' } .description= ${ 'PEM-encoded certificate' } ></dees-input-text>
2026-04-04 21:23:16 +00:00
</div>
</div>
2026-02-23 12:40:26 +00:00
</dees-form>
` ,
menuOptions : [
{
name : 'Cancel' ,
iconName : 'lucide:x' ,
action : async ( modalArg : any ) = > await modalArg . destroy ( ) ,
} ,
{
name : 'Create' ,
iconName : 'lucide:plus' ,
action : async ( modalArg : any ) = > {
const form = modalArg . shadowRoot ? . querySelector ( '.content' ) ? . querySelector ( 'dees-form' ) ;
if ( ! form ) return ;
const formData = await form . collectFormData ( ) ;
if ( ! formData . name || ! formData . ports ) return ;
const ports = formData . ports . split ( ',' ) . map ( ( p : string ) = > parseInt ( p . trim ( ) , 10 ) ) . filter ( ( p : number ) = > ! isNaN ( p ) ) ;
2026-04-02 22:55:57 +00:00
const domains : string [ ] = Array . isArray ( formData . domains )
? formData . domains . filter ( Boolean )
: [ ] ;
const priority = formData . priority ? parseInt ( formData . priority , 10 ) : undefined ;
2026-02-23 12:40:26 +00:00
const route : any = {
name : formData.name ,
match : {
ports ,
2026-04-02 22:55:57 +00:00
. . . ( domains . length > 0 ? { domains } : { } ) ,
2026-02-23 12:40:26 +00:00
} ,
action : {
type : 'forward' ,
targets : [
{
host : formData.targetHost || 'localhost' ,
2026-04-02 17:27:05 +00:00
port : parseInt ( formData . targetPort , 10 ) || 443 ,
2026-02-23 12:40:26 +00:00
} ,
] ,
} ,
2026-04-02 22:55:57 +00:00
. . . ( priority != null && ! isNaN ( priority ) ? { priority } : { } ) ,
2026-02-23 12:40:26 +00:00
} ;
2026-04-04 21:23:16 +00:00
// Build TLS config from form
const tlsModeValue = formData . tlsMode as any ;
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key ;
if ( tlsModeKey && tlsModeKey !== 'none' ) {
const tls : any = { mode : tlsModeKey } ;
if ( tlsModeKey !== 'passthrough' ) {
const tlsCertValue = formData . tlsCertificate as any ;
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key ;
if ( tlsCertKey === 'custom' && formData . tlsCertKey && formData . tlsCertCert ) {
tls . certificate = { key : formData.tlsCertKey , cert : formData.tlsCertCert } ;
} else {
tls . certificate = 'auto' ;
}
}
route . action . tls = tls ;
}
2026-04-02 17:27:05 +00:00
// Build metadata if profile/target selected
const metadata : any = { } ;
2026-04-05 00:37:37 +00:00
const profileRefValue = formData . sourceProfileRef as any ;
2026-04-04 11:00:03 +00:00
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key ;
if ( profileKey ) {
2026-04-05 00:37:37 +00:00
metadata . sourceProfileRef = profileKey ;
2026-04-02 17:27:05 +00:00
}
2026-04-04 11:00:03 +00:00
const targetRefValue = formData . networkTargetRef as any ;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key ;
if ( targetKey ) {
metadata . networkTargetRef = targetKey ;
2026-04-02 17:27:05 +00:00
}
2026-02-23 12:40:26 +00:00
await appstate . routeManagementStatePart . dispatchAction (
appstate . createRouteAction ,
2026-04-02 17:27:05 +00:00
{
route ,
metadata : Object.keys ( metadata ) . length > 0 ? metadata : undefined ,
} ,
2026-02-23 12:40:26 +00:00
) ;
await modalArg . destroy ( ) ;
} ,
} ,
] ,
} ) ;
2026-04-04 21:23:16 +00:00
// Setup conditional TLS field visibility after modal renders
const createForm = createModal ? . shadowRoot ? . querySelector ( '.content' ) ? . querySelector ( 'dees-form' ) as any ;
if ( createForm ) {
await createForm . updateComplete ;
setupTlsVisibility ( createForm ) ;
}
2026-02-23 12:40:26 +00:00
}
private refreshData() {
appstate . routeManagementStatePart . dispatchAction ( appstate . fetchMergedRoutesAction , null ) ;
}
async firstUpdated() {
await appstate . routeManagementStatePart . dispatchAction ( appstate . fetchMergedRoutesAction , null ) ;
}
}