2026-04-05 00:37:37 +00:00
import {
DeesElement ,
html ,
customElement ,
type TemplateResult ,
css ,
state ,
cssManager ,
} from '@design.estate/dees-element' ;
2026-04-08 07:45:26 +00:00
import * as plugins from '../../plugins.js' ;
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-04-05 00:37:37 +00:00
import { type IStatsTile } from '@design.estate/dees-catalog' ;
declare global {
interface HTMLElementTagNameMap {
'ops-view-targetprofiles' : OpsViewTargetProfiles ;
}
}
@customElement ( 'ops-view-targetprofiles' )
export class OpsViewTargetProfiles extends DeesElement {
@state ( )
accessor targetProfilesState : appstate.ITargetProfilesState = appstate . targetProfilesStatePart . getState ( ) ! ;
constructor ( ) {
super ( ) ;
const sub = appstate . targetProfilesStatePart . select ( ) . subscribe ( ( newState ) = > {
this . targetProfilesState = newState ;
} ) ;
this . rxSubscriptions . push ( sub ) ;
}
async connectedCallback() {
await super . connectedCallback ( ) ;
await appstate . targetProfilesStatePart . dispatchAction ( appstate . fetchTargetProfilesAction , null ) ;
}
public static styles = [
cssManager . defaultStyles ,
2026-04-08 08:24:55 +00:00
viewHostCss ,
2026-04-05 00:37:37 +00:00
css `
.profilesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.tagBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${ cssManager . bdTheme ( '#eff6ff' , '#172554' ) } ;
color: ${ cssManager . bdTheme ( '#1e40af' , '#60a5fa' ) } ;
margin-right: 4px;
margin-bottom: 2px;
}
` ,
] ;
public render ( ) : TemplateResult {
const profiles = this . targetProfilesState . profiles ;
const statsTiles : IStatsTile [ ] = [
{
id : 'totalProfiles' ,
title : 'Total Profiles' ,
type : 'number' ,
value : profiles.length ,
icon : 'lucide:target' ,
description : 'Reusable target profiles' ,
color : '#8b5cf6' ,
} ,
] ;
return html `
2026-04-08 11:08:18 +00:00
<dees-heading level="3">Target Profiles</dees-heading>
2026-04-05 00:37:37 +00:00
<div class="profilesContainer">
<dees-statsgrid .tiles= ${ statsTiles } ></dees-statsgrid>
<dees-table
.heading1= ${ 'Target Profiles' }
.heading2= ${ 'Define what resources VPN clients can access' }
.data= ${ profiles }
2026-04-08 07:11:21 +00:00
.showColumnFilters= ${ true }
2026-04-05 00:37:37 +00:00
.displayFunction= ${ ( profile : interfaces.data.ITargetProfile ) = > ( {
Name : profile.name ,
Description : profile.description || '-' ,
Domains : profile.domains?.length
? html ` ${ profile . domains . map ( d = > html ` <span class="tagBadge"> ${ d } </span> ` ) } `
: '-' ,
Targets : profile.targets?.length
2026-04-07 21:02:37 +00:00
? html ` ${ profile . targets . map ( t = > html ` <span class="tagBadge"> ${ t . ip } : ${ t . port } </span> ` ) } `
2026-04-05 00:37:37 +00:00
: '-' ,
'Route Refs' : profile . routeRefs ? . length
2026-04-13 23:02:42 +00:00
? html ` ${ profile . routeRefs . map ( r = > html ` <span class="tagBadge"> ${ this . formatRouteRef ( r ) } </span> ` ) } `
2026-04-05 00:37:37 +00:00
: '-' ,
2026-05-24 05:11:48 +00:00
'Source-Policy Route Grants' : profile . allowRoutesByClientSourceIp ? 'Yes' : 'No' ,
2026-04-05 00:37:37 +00:00
Created : new Date ( profile . createdAt ) . toLocaleDateString ( ) ,
} )}
.dataActions= ${ [
{
name : 'Create Profile' ,
iconName : 'lucide:plus' ,
type : [ 'header' as const ] ,
actionFunc : async ( ) = > {
await this . showCreateProfileDialog ( ) ;
} ,
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
},
},
{
name: 'Detail',
iconName: 'lucide:info',
type: ['doubleClick'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showDetailDialog(profile);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showEditProfileDialog(profile);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.deleteProfile(profile);
},
},
]}
></dees-table>
</div>
` ;
}
2026-04-13 23:02:42 +00:00
private getRouteChoices() {
2026-04-05 11:29:47 +00:00
const routeState = appstate . routeManagementStatePart . getState ( ) ;
const routes = routeState ? . mergedRoutes || [ ] ;
return routes
2026-04-13 23:02:42 +00:00
. filter ( ( mr ) = > mr . route . name && mr . id )
. map ( ( mr ) = > ( {
routeId : mr.id ! ,
routeName : mr.route.name ! ,
label : ` ${ mr . route . name } ( ${ mr . id } ) ` ,
} ) ) ;
}
private getRouteCandidates() {
return this . getRouteChoices ( ) . map ( ( route ) = > ( { viewKey : route.label } ) ) ;
}
private resolveRouteRefsToLabels ( routeRefs? : string [ ] ) : string [ ] | undefined {
if ( ! routeRefs ? . length ) return undefined ;
const routeChoices = this . getRouteChoices ( ) ;
const routeById = new Map ( routeChoices . map ( ( route ) = > [ route . routeId , route . label ] ) ) ;
const routeByName = new Map < string , string [ ] > ( ) ;
for ( const route of routeChoices ) {
const labels = routeByName . get ( route . routeName ) || [ ] ;
labels . push ( route . label ) ;
routeByName . set ( route . routeName , labels ) ;
}
return routeRefs . map ( ( routeRef ) = > {
const routeLabel = routeById . get ( routeRef ) ;
if ( routeLabel ) return routeLabel ;
const labelsForName = routeByName . get ( routeRef ) || [ ] ;
if ( labelsForName . length === 1 ) return labelsForName [ 0 ] ;
return routeRef ;
} ) ;
}
private resolveRouteLabelsToRefs ( routeRefs : string [ ] ) : string [ ] {
if ( ! routeRefs . length ) return [ ] ;
const labelToId = new Map (
this . getRouteChoices ( ) . map ( ( route ) = > [ route . label , route . routeId ] ) ,
) ;
return routeRefs . map ( ( routeRef ) = > labelToId . get ( routeRef ) || routeRef ) ;
}
private formatRouteRef ( routeRef : string ) : string {
return this . resolveRouteRefsToLabels ( [ routeRef ] ) ? . [ 0 ] || routeRef ;
2026-04-05 11:29:47 +00:00
}
2026-04-05 13:48:08 +00:00
private async ensureRoutesLoaded() {
const routeState = appstate . routeManagementStatePart . getState ( ) ;
if ( ! routeState ? . mergedRoutes ? . length ) {
await appstate . routeManagementStatePart . dispatchAction ( appstate . fetchMergedRoutesAction , null ) ;
}
}
2026-04-05 00:37:37 +00:00
private async showCreateProfileDialog() {
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
2026-04-05 13:48:08 +00:00
await this . ensureRoutesLoaded ( ) ;
2026-04-05 11:29:47 +00:00
const routeCandidates = this . getRouteCandidates ( ) ;
2026-04-05 00:37:37 +00:00
DeesModal . createAndShow ( {
heading : 'Create Target Profile' ,
content : html `
<dees-form>
<dees-input-text .key= ${ 'name' } .label= ${ 'Name' } .required= ${ true } ></dees-input-text>
<dees-input-text .key= ${ 'description' } .label= ${ 'Description' } ></dees-input-text>
2026-04-05 11:29:47 +00:00
<dees-input-list .key= ${ 'domains' } .label= ${ 'Domains' } .placeholder= ${ 'e.g. *.example.com' } .allowFreeform= ${ true } ></dees-input-list>
2026-04-12 19:42:07 +00:00
<dees-input-list .key= ${ 'targets' } .label= ${ 'Targets' } .description= ${ 'Format: ip:port, e.g. 10.0.0.1:443' } .placeholder= ${ 'e.g. 10.0.0.1:443' } .allowFreeform= ${ true } ></dees-input-list>
2026-04-05 11:29:47 +00:00
<dees-input-list .key= ${ 'routeRefs' } .label= ${ 'Route Refs' } .placeholder= ${ 'Type to search routes...' } .candidates= ${ routeCandidates } .allowFreeform= ${ true } ></dees-input-list>
2026-05-24 05:11:48 +00:00
<dees-input-checkbox .key= ${ 'allowRoutesByClientSourceIp' } .label= ${ 'Allow source-policy route grants' } .description= ${ 'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection' } .value= ${ false } ></dees-input-checkbox>
2026-04-05 00:37:37 +00:00
</dees-form>
` ,
menuOptions : [
{ name : 'Cancel' , iconName : 'lucide:x' , action : async ( modalArg : any ) = > modalArg . destroy ( ) } ,
{
name : 'Create' ,
iconName : 'lucide:plus' ,
action : async ( modalArg : any ) = > {
const form = modalArg . shadowRoot ? . querySelector ( '.content' ) ? . querySelector ( 'dees-form' ) ;
if ( ! form ) return ;
const data = await form . collectFormData ( ) ;
if ( ! data . name ) return ;
2026-04-05 11:29:47 +00:00
const domains : string [ ] = Array . isArray ( data . domains ) ? data . domains : [ ] ;
const targetStrings : string [ ] = Array . isArray ( data . targets ) ? data . targets : [ ] ;
const targets = targetStrings
. map ( ( s : string ) = > {
const lastColon = s . lastIndexOf ( ':' ) ;
if ( lastColon === - 1 ) return null ;
return {
2026-04-07 21:02:37 +00:00
ip : s.substring ( 0 , lastColon ) ,
2026-04-05 11:29:47 +00:00
port : parseInt ( s . substring ( lastColon + 1 ) , 10 ) ,
} ;
} )
2026-04-07 21:02:37 +00:00
. filter ( ( t ) : t is { ip : string ; port : number } = > t !== null && ! isNaN ( t . port ) ) ;
2026-04-13 23:02:42 +00:00
const routeRefs = this . resolveRouteLabelsToRefs (
Array . isArray ( data . routeRefs ) ? data . routeRefs : [ ] ,
) ;
2026-04-05 00:37:37 +00:00
await appstate . targetProfilesStatePart . dispatchAction ( appstate . createTargetProfileAction , {
name : String ( data . name ) ,
description : data.description ? String ( data . description ) : undefined ,
2026-04-05 11:29:47 +00:00
domains : domains.length > 0 ? domains : undefined ,
targets : targets.length > 0 ? targets : undefined ,
routeRefs : routeRefs.length > 0 ? routeRefs : undefined ,
2026-05-21 23:44:01 +00:00
allowRoutesByClientSourceIp : data.allowRoutesByClientSourceIp === true ,
2026-04-05 00:37:37 +00:00
} ) ;
modalArg . destroy ( ) ;
} ,
} ,
] ,
} ) ;
}
private async showEditProfileDialog ( profile : interfaces.data.ITargetProfile ) {
2026-04-05 11:29:47 +00:00
const currentDomains = profile . domains || [ ] ;
2026-04-07 21:02:37 +00:00
const currentTargets = profile . targets ? . map ( t = > ` ${ t . ip } : ${ t . port } ` ) || [ ] ;
2026-04-13 23:02:42 +00:00
const currentRouteRefs = this . resolveRouteRefsToLabels ( profile . routeRefs ) || [ ] ;
2026-04-05 00:37:37 +00:00
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
2026-04-05 13:48:08 +00:00
await this . ensureRoutesLoaded ( ) ;
2026-04-05 11:29:47 +00:00
const routeCandidates = this . getRouteCandidates ( ) ;
2026-04-05 00:37:37 +00:00
DeesModal . createAndShow ( {
heading : ` Edit Profile: ${ profile . name } ` ,
content : html `
<dees-form>
<dees-input-text .key= ${ 'name' } .label= ${ 'Name' } .value= ${ profile . name } ></dees-input-text>
<dees-input-text .key= ${ 'description' } .label= ${ 'Description' } .value= ${ profile . description || '' } ></dees-input-text>
2026-04-05 11:29:47 +00:00
<dees-input-list .key= ${ 'domains' } .label= ${ 'Domains' } .placeholder= ${ 'e.g. *.example.com' } .allowFreeform= ${ true } .value= ${ currentDomains } ></dees-input-list>
2026-04-12 19:42:07 +00:00
<dees-input-list .key= ${ 'targets' } .label= ${ 'Targets' } .description= ${ 'Format: ip:port, e.g. 10.0.0.1:443' } .placeholder= ${ 'e.g. 10.0.0.1:443' } .allowFreeform= ${ true } .value= ${ currentTargets } ></dees-input-list>
2026-04-05 11:29:47 +00:00
<dees-input-list .key= ${ 'routeRefs' } .label= ${ 'Route Refs' } .placeholder= ${ 'Type to search routes...' } .candidates= ${ routeCandidates } .allowFreeform= ${ true } .value= ${ currentRouteRefs } ></dees-input-list>
2026-05-24 05:11:48 +00:00
<dees-input-checkbox .key= ${ 'allowRoutesByClientSourceIp' } .label= ${ 'Allow source-policy route grants' } .description= ${ 'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection' } .value= ${ profile . allowRoutesByClientSourceIp === true } ></dees-input-checkbox>
2026-04-05 00:37:37 +00:00
</dees-form>
` ,
menuOptions : [
{ name : 'Cancel' , iconName : 'lucide:x' , action : async ( modalArg : any ) = > modalArg . destroy ( ) } ,
{
name : 'Save' ,
iconName : 'lucide:check' ,
action : async ( modalArg : any ) = > {
const form = modalArg . shadowRoot ? . querySelector ( '.content' ) ? . querySelector ( 'dees-form' ) ;
if ( ! form ) return ;
const data = await form . collectFormData ( ) ;
2026-04-05 11:29:47 +00:00
const domains : string [ ] = Array . isArray ( data . domains ) ? data . domains : [ ] ;
const targetStrings : string [ ] = Array . isArray ( data . targets ) ? data . targets : [ ] ;
const targets = targetStrings
. map ( ( s : string ) = > {
const lastColon = s . lastIndexOf ( ':' ) ;
if ( lastColon === - 1 ) return null ;
return {
2026-04-07 21:02:37 +00:00
ip : s.substring ( 0 , lastColon ) ,
2026-04-05 11:29:47 +00:00
port : parseInt ( s . substring ( lastColon + 1 ) , 10 ) ,
} ;
} )
2026-04-07 21:02:37 +00:00
. filter ( ( t ) : t is { ip : string ; port : number } = > t !== null && ! isNaN ( t . port ) ) ;
2026-04-13 23:02:42 +00:00
const routeRefs = this . resolveRouteLabelsToRefs (
Array . isArray ( data . routeRefs ) ? data . routeRefs : [ ] ,
) ;
2026-04-05 00:37:37 +00:00
await appstate . targetProfilesStatePart . dispatchAction ( appstate . updateTargetProfileAction , {
id : profile.id ,
name : String ( data . name ) ,
description : data.description ? String ( data . description ) : undefined ,
domains ,
targets ,
routeRefs ,
2026-05-21 23:44:01 +00:00
allowRoutesByClientSourceIp : data.allowRoutesByClientSourceIp === true ,
2026-04-05 00:37:37 +00:00
} ) ;
modalArg . destroy ( ) ;
} ,
} ,
] ,
} ) ;
}
private async showDetailDialog ( profile : interfaces.data.ITargetProfile ) {
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
// Fetch usage (which VPN clients reference this profile)
let usageHtml = html ` <p style="color: #9ca3af;">Loading usage...</p> ` ;
try {
const request = new plugins . domtools . plugins . typedrequest . TypedRequest <
interfaces . requests . IReq_GetTargetProfileUsage
> ( '/typedrequest' , 'getTargetProfileUsage' ) ;
const response = await request . fire ( {
identity : appstate.loginStatePart.getState ( ) ! . identity ! ,
id : profile.id ,
} ) ;
if ( response . clients . length > 0 ) {
usageHtml = html `
<div style="margin-top: 8px;">
${ response . clients . map ( c = > html `
<div style="padding: 4px 0; font-size: 13px;">
<strong> ${ c . clientId } </strong> ${ c . description ? html ` - ${ c . description } ` : '' }
</div>
` ) }
</div>
` ;
} else {
usageHtml = html ` <p style="color: #9ca3af; font-size: 13px;">No VPN clients reference this profile.</p> ` ;
}
} catch {
usageHtml = html ` <p style="color: #9ca3af;">Usage data unavailable.</p> ` ;
}
DeesModal . createAndShow ( {
heading : ` Target Profile: ${ profile . name } ` ,
content : html `
<div style="display: flex; flex-direction: column; gap: 12px;">
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">Description</div>
<div style="font-size: 14px; margin-top: 4px;"> ${ profile . description || '-' } </div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">Domains</div>
<div style="font-size: 14px; margin-top: 4px;">
${ profile . domains ? . length
? profile . domains . map ( d = > html ` <span class="tagBadge"> ${ d } </span> ` )
: '-' }
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">Targets</div>
<div style="font-size: 14px; margin-top: 4px;">
${ profile . targets ? . length
2026-04-07 21:02:37 +00:00
? profile . targets . map ( t = > html ` <span class="tagBadge"> ${ t . ip } : ${ t . port } </span> ` )
2026-04-05 00:37:37 +00:00
: '-' }
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">Route Refs</div>
<div style="font-size: 14px; margin-top: 4px;">
${ profile . routeRefs ? . length
2026-04-13 23:02:42 +00:00
? profile . routeRefs . map ( r = > html ` <span class="tagBadge"> ${ this . formatRouteRef ( r ) } </span> ` )
2026-04-05 00:37:37 +00:00
: '-' }
</div>
</div>
2026-05-21 23:44:01 +00:00
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">Client Source IP Routes</div>
<div style="font-size: 14px; margin-top: 4px;"> ${ profile . allowRoutesByClientSourceIp ? 'Enabled' : 'Disabled' } </div>
</div>
2026-04-05 00:37:37 +00:00
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">Created</div>
<div style="font-size: 14px; margin-top: 4px;"> ${ new Date ( profile . createdAt ) . toLocaleString ( ) } by ${ profile . createdBy } </div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">Updated</div>
<div style="font-size: 14px; margin-top: 4px;"> ${ new Date ( profile . updatedAt ) . toLocaleString ( ) } </div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;">VPN Clients Using This Profile</div>
${ usageHtml }
</div>
</div>
` ,
menuOptions : [
{ name : 'Close' , iconName : 'lucide:x' , action : async ( m : any ) = > await m . destroy ( ) } ,
] ,
} ) ;
}
private async deleteProfile ( profile : interfaces.data.ITargetProfile ) {
await appstate . targetProfilesStatePart . dispatchAction ( appstate . deleteTargetProfileAction , {
id : profile.id ,
force : false ,
} ) ;
const currentState = appstate . targetProfilesStatePart . getState ( ) ! ;
if ( currentState . error ? . includes ( 'in use' ) ) {
const { DeesModal } = await import ( '@design.estate/dees-catalog' ) ;
DeesModal . createAndShow ( {
heading : 'Profile In Use' ,
content : html ` <p> ${ currentState . error } Force delete?</p> ` ,
menuOptions : [
{
name : 'Force Delete' ,
iconName : 'lucide:trash2' ,
action : async ( modalArg : any ) = > {
await appstate . targetProfilesStatePart . dispatchAction ( appstate . deleteTargetProfileAction , {
id : profile.id ,
force : true ,
} ) ;
modalArg . destroy ( ) ;
} ,
} ,
{ name : 'Cancel' , iconName : 'lucide:x' , action : async ( modalArg : any ) = > modalArg . destroy ( ) } ,
] ,
} ) ;
}
}
}